Commit 295ff457 authored by Administrator's avatar Administrator

Update 32 files via Son of Anton

parent 3bff7dd8
<?php
declare(strict_types=1);
namespace Modules\Adjustments\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
final class AdjustmentController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'adjustments.view.any');
$month = $request->query('month', date('Y-m'));
$adjustments = $this->db->fetchAll(
"SELECT ma.*, u.full_name_en as contractor_name, c.full_name_en as created_by_name
FROM manual_adjustments ma
JOIN users u ON u.id = ma.contractor_id
JOIN users c ON c.id = ma.created_by_id
WHERE ma.effective_month = ? AND ma.deleted_at IS NULL ORDER BY ma.created_at DESC",
[$month]
);
return Response::json(['adjustments' => $adjustments, 'month' => $month]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'adjustments.create');
$isSA = $user['role'] === 'super_admin';
$status = $isSA ? 'approved' : 'pending_approval';
$id = $this->db->insert('manual_adjustments', [
'contractor_id' => (int)$request->input('contractor_id'),
'type' => $request->input('type'),
'amount' => (float)$request->input('amount'),
'category' => $request->input('category'),
'description' => $request->input('description'),
'effective_month' => $request->input('effective_month', date('Y-m')),
'status' => $status,
'created_by_id' => $user['id'],
'reviewed_by_id' => $isSA ? $user['id'] : null,
'reviewed_at' => $isSA ? date('Y-m-d H:i:s') : null,
]);
if ($isSA) {
$contractor = $this->db->fetchOne("SELECT full_name_en FROM users WHERE id = ?", [(int)$request->input('contractor_id')]);
$this->notif->createImportant((int)$request->input('contractor_id'), 'Salary Adjustment',
"A {$request->input('type')} adjustment of " . number_format((float)$request->input('amount'), 2) . " EGP has been applied.");
} else {
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], 'Adjustment Pending Approval',
"Manual adjustment #{$id} requires approval.");
}
}
return Response::json(['success' => true, 'id' => $id]);
}
public function approve(Request $request, string $id): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'adjustments.approve');
$adj = $this->db->fetchOne("SELECT * FROM manual_adjustments WHERE id = ? AND deleted_at IS NULL", [(int)$id]);
if (!$adj) return Response::json(['error' => 'Not found'], 404);
$action = $request->input('action');
$newStatus = $action === 'approve' ? 'approved' : 'rejected';
$this->db->update('manual_adjustments', [
'status' => $newStatus,
'reviewed_by_id' => $user['id'],
'reviewed_at' => date('Y-m-d H:i:s'),
'review_note' => $request->input('review_note'),
], 'id = ?', [(int)$id]);
if ($newStatus === 'approved') {
$this->notif->createImportant($adj['contractor_id'], 'Salary Adjustment Applied',
"A {$adj['type']} adjustment of " . number_format($adj['amount'], 2) . " EGP has been approved.");
}
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/adjustments',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Adjustments\Controllers\AdjustmentController::class, 'index');
$router->post('/', Modules\Adjustments\Controllers\AdjustmentController::class, 'create');
$router->post('/{id}/review', Modules\Adjustments\Controllers\AdjustmentController::class, 'approve');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Boards\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Validation\Validator;
use Engine\Audit\AuditLogger;
use Engine\Core\Config;
use Engine\Template\TemplateEngine;
final class BoardController
{
private Connection $db;
private PermissionEngine $perms;
private Validator $validator;
private AuditLogger $audit;
private Config $config;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->validator = $c->resolve(Validator::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->config = $c->resolve(Config::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
if (in_array($user['role'], ['super_admin', 'admin'])) {
$boards = $this->db->fetchAll(
"SELECT b.*, (SELECT COUNT(*) FROM board_members WHERE board_id = b.id) as member_count,
(SELECT COUNT(*) FROM cards WHERE board_id = b.id AND is_archived = 0) as card_count
FROM boards b WHERE b.is_archived = 0 ORDER BY b.name"
);
} else {
$boards = $this->db->fetchAll(
"SELECT b.*, (SELECT COUNT(*) FROM board_members WHERE board_id = b.id) as member_count,
(SELECT COUNT(*) FROM cards WHERE board_id = b.id AND is_archived = 0) as card_count
FROM boards b JOIN board_members bm ON bm.board_id = b.id
WHERE bm.user_id = ? AND b.is_archived = 0 ORDER BY b.name",
[$user['id']]
);
}
if ($request->wantsJson()) {
return Response::json(['boards' => $boards]);
}
return Response::html($this->templates->render('boards/index', ['user' => $user, 'boards' => $boards]));
}
public function show(Request $request, string $boardId): Response
{
$user = $request->user();
$board = $this->getBoard((int)$boardId, $user);
if (!$board) {
return Response::json(['error' => 'Board not found or access denied'], 404);
}
$columns = $this->db->fetchAll(
"SELECT * FROM board_columns WHERE board_id = ? ORDER BY position",
[$board['id']]
);
$cards = $this->db->fetchAll(
"SELECT c.*, GROUP_CONCAT(DISTINCT ca.user_id) as assignee_ids
FROM cards c LEFT JOIN card_assignments ca ON ca.card_id = c.id
WHERE c.board_id = ? AND c.is_archived = 0
GROUP BY c.id ORDER BY c.position_in_column",
[$board['id']]
);
$members = $this->db->fetchAll(
"SELECT u.id, u.full_name_en, u.username, u.profile_photo_id, bm.role_on_board
FROM users u JOIN board_members bm ON bm.user_id = u.id WHERE bm.board_id = ?",
[$board['id']]
);
$labels = $this->db->fetchAll(
"SELECT * FROM labels WHERE (scope = 'organization' AND board_id IS NULL) OR (scope = 'board' AND board_id = ?)
ORDER BY scope DESC, text",
[$board['id']]
);
// Group cards by column
$cardsByColumn = [];
foreach ($columns as $col) {
$cardsByColumn[$col['id']] = [];
}
foreach ($cards as $card) {
$card['assignee_ids'] = $card['assignee_ids'] ? explode(',', $card['assignee_ids']) : [];
$card['labels'] = $this->db->fetchAll(
"SELECT l.* FROM labels l JOIN card_labels cl ON cl.label_id = l.id WHERE cl.card_id = ?",
[$card['id']]
);
$cardsByColumn[$card['column_id']][] = $card;
}
$data = [
'user' => $user,
'board' => $board,
'columns' => $columns,
'cards_by_column' => $cardsByColumn,
'members' => $members,
'labels' => $labels,
];
if ($request->wantsJson()) {
return Response::json($data);
}
return Response::html($this->templates->render('boards/show', $data));
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'boards.create');
$valid = $this->validator->validate($request->all(), [
'name' => 'required|min:2|max:200',
'board_key' => 'required|min:2|max:10|unique:boards,board_key',
'description' => 'string',
]);
if (!$valid) {
return Response::json(['errors' => $this->validator->errors()], 422);
}
$boardId = $this->db->transaction(function () use ($request, $user) {
$boardId = $this->db->insert('boards', [
'name' => $request->input('name'),
'description' => $request->input('description'),
'board_key' => strtoupper($request->input('board_key')),
'allow_contractor_card_creation' => (int)($request->input('allow_contractor_card_creation', 1)),
'auto_archive_done_days' => (int)($request->input('auto_archive_done_days', 30)),
'deadline_excludes_holidays' => (int)($request->input('deadline_excludes_holidays', 0)),
'created_by_id' => $user['id'],
]);
// Create default columns
$defaultColumns = $this->config->all('default_columns');
foreach ($defaultColumns as $col) {
$this->db->insert('board_columns', [
'board_id' => $boardId,
'name' => $col['name'],
'slug' => $col['slug'],
'icon' => $col['icon'],
'position' => $col['position'],
'is_system' => $col['is_system'] ? 1 : 0,
]);
}
// Add creator as member
$this->db->insert('board_members', [
'board_id' => $boardId,
'user_id' => $user['id'],
'role_on_board' => in_array($user['role'], ['super_admin', 'admin']) ? 'project_leader' : 'member',
]);
return $boardId;
});
$this->audit->log($user, 'BOARD_CREATED', 'board', $boardId, 'boards', '/boards',
null, ['name' => $request->input('name')], $request->ip(), $request->userAgent());
return Response::json(['success' => true, 'id' => $boardId, 'redirect' => "/boards/{$boardId}"]);
}
public function update(Request $request, string $boardId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'boards.edit');
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ?", [(int)$boardId]);
if (!$board) {
return Response::json(['error' => 'Board not found'], 404);
}
$data = $request->only(['name', 'description', 'allow_contractor_card_creation', 'auto_archive_done_days', 'deadline_excludes_holidays']);
$data = array_filter($data, fn($v) => $v !== null);
if (isset($data['allow_contractor_card_creation'])) $data['allow_contractor_card_creation'] = (int)$data['allow_contractor_card_creation'];
if (isset($data['auto_archive_done_days'])) $data['auto_archive_done_days'] = (int)$data['auto_archive_done_days'];
if (isset($data['deadline_excludes_holidays'])) $data['deadline_excludes_holidays'] = (int)$data['deadline_excludes_holidays'];
$before = $board;
$this->db->update('boards', $data, 'id = ?', [(int)$boardId]);
$this->audit->log($user, 'BOARD_UPDATED', 'board', (int)$boardId, 'boards', "/boards/{$boardId}",
$before, $data, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function archive(Request $request, string $boardId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'boards.archive');
$this->db->update('boards', ['is_archived' => 1, 'archived_at' => date('Y-m-d H:i:s')], 'id = ?', [(int)$boardId]);
$this->audit->log($user, 'BOARD_ARCHIVED', 'board', (int)$boardId, 'boards', "/boards/{$boardId}",
null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function addMember(Request $request, string $boardId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'boards.manage_members');
$memberId = (int)$request->input('user_id');
$roleOnBoard = $request->input('role_on_board', 'member');
$exists = $this->db->fetchOne("SELECT id FROM board_members WHERE board_id = ? AND user_id = ?", [(int)$boardId, $memberId]);
if ($exists) {
return Response::json(['error' => 'User already a member'], 422);
}
$this->db->insert('board_members', [
'board_id' => (int)$boardId,
'user_id' => $memberId,
'role_on_board' => $roleOnBoard,
]);
$board = $this->db->fetchOne("SELECT name FROM boards WHERE id = ?", [(int)$boardId]);
$this->notifMember($memberId, $board['name'] ?? 'Board');
return Response::json(['success' => true]);
}
public function removeMember(Request $request, string $boardId, string $memberId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'boards.manage_members');
$this->db->delete('board_members', 'board_id = ? AND user_id = ?', [(int)$boardId, (int)$memberId]);
return Response::json(['success' => true]);
}
public function addColumn(Request $request, string $boardId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'columns.create');
$customCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM board_columns WHERE board_id = ? AND is_system = 0",
[(int)$boardId]
);
if ($customCount >= 5) {
return Response::json(['error' => 'Maximum 5 custom columns allowed'], 422);
}
$valid = $this->validator->validate($request->all(), [
'name' => 'required|min:1|max:50',
'icon' => 'string',
]);
if (!$valid) {
return Response::json(['errors' => $this->validator->errors()], 422);
}
// Position custom columns between frozen and in_review
$inReviewPos = (int)$this->db->fetchColumn(
"SELECT position FROM board_columns WHERE board_id = ? AND slug = 'in_review'",
[(int)$boardId]
);
// Shift in_review and done up
$this->db->query(
"UPDATE board_columns SET position = position + 1 WHERE board_id = ? AND position >= ?",
[(int)$boardId, $inReviewPos]
);
$slug = 'custom_' . preg_replace('/[^a-z0-9]/', '_', strtolower($request->input('name')));
$colId = $this->db->insert('board_columns', [
'board_id' => (int)$boardId,
'name' => $request->input('name'),
'slug' => $slug,
'icon' => $request->input('icon'),
'position' => $inReviewPos,
'is_system' => 0,
]);
return Response::json(['success' => true, 'id' => $colId]);
}
private function getBoard(int $boardId, array $user): ?array
{
if (in_array($user['role'], ['super_admin', 'admin'])) {
return $this->db->fetchOne("SELECT * FROM boards WHERE id = ? AND is_archived = 0", [$boardId]);
}
return $this->db->fetchOne(
"SELECT b.* FROM boards b JOIN board_members bm ON bm.board_id = b.id
WHERE b.id = ? AND bm.user_id = ? AND b.is_archived = 0",
[$boardId, $user['id']]
);
}
private function notifMember(int $userId, string $boardName): void
{
$notif = Container::getInstance()->resolve(\Engine\Notifications\NotificationManager::class);
$notif->createImportant($userId, 'Added to Board', "You've been added to board: {$boardName}");
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/boards',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->get('/', Modules\Boards\Controllers\BoardController::class, 'index');
$router->post('/', Modules\Boards\Controllers\BoardController::class, 'create');
$router->get('/{boardId}', Modules\Boards\Controllers\BoardController::class, 'show');
$router->post('/{boardId}/update', Modules\Boards\Controllers\BoardController::class, 'update');
$router->post('/{boardId}/archive', Modules\Boards\Controllers\BoardController::class, 'archive');
$router->post('/{boardId}/members', Modules\Boards\Controllers\BoardController::class, 'addMember');
$router->post('/{boardId}/members/{memberId}/remove', Modules\Boards\Controllers\BoardController::class, 'removeMember');
$router->post('/{boardId}/columns', Modules\Boards\Controllers\BoardController::class, 'addColumn');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Cards\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Validation\Validator;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
use Engine\Events\EventDispatcher;
use Engine\Template\TemplateEngine;
final class CardController
{
private Connection $db;
private PermissionEngine $perms;
private Validator $validator;
private AuditLogger $audit;
private NotificationManager $notif;
private EventDispatcher $events;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->validator = $c->resolve(Validator::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->events = $c->resolve(EventDispatcher::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function show(Request $request, string $cardId): Response
{
$user = $request->user();
$card = $this->db->fetchOne("SELECT * FROM cards WHERE id = ?", [(int)$cardId]);
if (!$card) {
return Response::json(['error' => 'Card not found'], 404);
}
$card['assignees'] = $this->db->fetchAll(
"SELECT u.id, u.full_name_en, u.username, u.profile_photo_id, ca.assigned_by_id, ca.created_at
FROM users u JOIN card_assignments ca ON ca.user_id = u.id WHERE ca.card_id = ?",
[$card['id']]
);
$card['labels'] = $this->db->fetchAll(
"SELECT l.* FROM labels l JOIN card_labels cl ON cl.label_id = l.id WHERE cl.card_id = ?",
[$card['id']]
);
$card['checklists'] = $this->getChecklists($card['id']);
$card['comments'] = $this->db->fetchAll(
"SELECT cc.*, u.full_name_en as author_name, u.profile_photo_id as author_photo_id
FROM card_comments cc JOIN users u ON u.id = cc.author_id
WHERE cc.card_id = ? ORDER BY cc.created_at ASC",
[$card['id']]
);
$card['activity'] = $this->db->fetchAll(
"SELECT cal.*, u.full_name_en as user_name FROM card_activity_log cal
LEFT JOIN users u ON u.id = cal.user_id
WHERE cal.card_id = ? ORDER BY cal.created_at ASC LIMIT 100",
[$card['id']]
);
$card['attachments'] = $this->db->fetchAll(
"SELECT ca.*, f.original_name, f.mime_type, f.size_bytes, u.full_name_en as uploaded_by_name
FROM card_attachments ca JOIN file_uploads f ON f.id = ca.file_id
JOIN users u ON u.id = ca.uploaded_by_id WHERE ca.card_id = ?",
[$card['id']]
);
$card['dependencies'] = $this->db->fetchAll(
"SELECT cd.*, c.card_key, c.title, c.done_at FROM card_dependencies cd
JOIN cards c ON c.id = cd.depends_on_card_id WHERE cd.card_id = ?",
[$card['id']]
);
$card['watchers'] = $this->db->fetchAll(
"SELECT u.id, u.full_name_en FROM users u JOIN card_watchers cw ON cw.user_id = u.id WHERE cw.card_id = ?",
[$card['id']]
);
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ?", [$card['board_id']]);
$column = $this->db->fetchOne("SELECT * FROM board_columns WHERE id = ?", [$card['column_id']]);
$columns = $this->db->fetchAll("SELECT * FROM board_columns WHERE board_id = ? ORDER BY position", [$card['board_id']]);
$data = ['user' => $user, 'card' => $card, 'board' => $board, 'column' => $column, 'columns' => $columns];
if ($request->wantsJson()) {
return Response::json($data);
}
return Response::html($this->templates->render('cards/detail', $data));
}
public function create(Request $request, string $boardId): Response
{
$user = $request->user();
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ? AND is_archived = 0", [(int)$boardId]);
if (!$board) {
return Response::json(['error' => 'Board not found'], 404);
}
// Permission check
$canCreate = $this->perms->can($user, 'cards.create.any') ||
($user['role'] === 'project_leader' && $this->perms->can($user, 'cards.create.own_boards', ['board_id' => $board['id']])) ||
($user['role'] === 'contractor' && $board['allow_contractor_card_creation']);
if (!$canCreate) {
return Response::json(['error' => 'Permission denied'], 403);
}
$valid = $this->validator->validate($request->all(), [
'title' => 'required|min:1|max:200',
]);
if (!$valid) {
return Response::json(['errors' => $this->validator->errors()], 422);
}
// Determine target column
$targetColumnSlug = ($user['role'] === 'contractor') ? 'backlog' : ($request->input('column_slug', 'backlog'));
$column = $this->db->fetchOne(
"SELECT id FROM board_columns WHERE board_id = ? AND slug = ?",
[$board['id'], $targetColumnSlug]
);
if (!$column) {
$column = $this->db->fetchOne("SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$board['id']]);
}
$cardId = $this->db->transaction(function () use ($board, $column, $request, $user) {
// Increment card sequence
$this->db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [$board['id']]);
$updatedBoard = $this->db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [$board['id']]);
$cardNumber = $updatedBoard['card_sequence'];
$cardKey = $board['board_key'] . '-' . $cardNumber;
$maxPos = (int)$this->db->fetchColumn(
"SELECT COALESCE(MAX(position_in_column), 0) FROM cards WHERE column_id = ? AND is_archived = 0",
[$column['id']]
);
$cardId = $this->db->insert('cards', [
'board_id' => $board['id'],
'column_id' => $column['id'],
'card_number' => $cardNumber,
'card_key' => $cardKey,
'title' => $request->input('title'),
'description' => $request->input('description'),
'priority' => $request->input('priority', 'none'),
'estimated_hours' => $request->input('estimated_hours') ? (float)$request->input('estimated_hours') : null,
'deadline' => $request->input('deadline') ?: null,
'position_in_column' => $maxPos + 1,
'created_by_id' => $user['id'],
]);
$this->logCardActivity($cardId, $user['id'], 'created', ['card_key' => $cardKey]);
return $cardId;
});
$this->events->fire('card.created', ['card_id' => $cardId, 'board_id' => $board['id'], 'user' => $user]);
return Response::json(['success' => true, 'id' => $cardId]);
}
public function update(Request $request, string $cardId): Response
{
$user = $request->user();
$card = $this->db->fetchOne("SELECT * FROM cards WHERE id = ?", [(int)$cardId]);
if (!$card) {
return Response::json(['error' => 'Card not found'], 404);
}
// Optimistic locking
$expectedVersion = (int)$request->input('version', $card['version']);
if ($expectedVersion !== $card['version']) {
return Response::json(['error' => 'Card was modified by another user. Please refresh.', 'conflict' => true], 409);
}
$allowed = ['title', 'description', 'priority', 'estimated_hours', 'deadline', 'frozen_reason'];
$data = array_intersect_key($request->all(), array_flip($allowed));
$data = array_filter($data, fn($v) => $v !== null);
if (isset($data['estimated_hours'])) $data['estimated_hours'] = (float)$data['estimated_hours'];
if (isset($data['deadline']) && $data['deadline'] === '') $data['deadline'] = null;
$data['version'] = $card['version'] + 1;
$before = array_intersect_key($card, $data);
$updated = $this->db->update('cards', $data, 'id = ? AND version = ?', [(int)$cardId, $card['version']]);
if ($updated === 0) {
return Response::json(['error' => 'Update conflict. Refresh and try again.', 'conflict' => true], 409);
}
foreach ($data as $field => $newVal) {
if ($field === 'version') continue;
$oldVal = $before[$field] ?? null;
if ($oldVal !== $newVal) {
$this->logCardActivity((int)$cardId, $user['id'], "{$field}_changed", [
'from' => $oldVal, 'to' => $newVal
]);
}
}
return Response::json(['success' => true, 'version' => $data['version']]);
}
public function move(Request $request, string $cardId): Response
{
$user = $request->user();
$card = $this->db->fetchOne("SELECT c.*, bc.slug as current_slug FROM cards c JOIN board_columns bc ON bc.id = c.column_id WHERE c.id = ?", [(int)$cardId]);
if (!$card) {
return Response::json(['error' => 'Card not found'], 404);
}
$targetColumnId = (int)$request->input('column_id');
$targetColumn = $this->db->fetchOne("SELECT * FROM board_columns WHERE id = ? AND board_id = ?", [$targetColumnId, $card['board_id']]);
if (!$targetColumn) {
return Response::json(['error' => 'Invalid target column'], 422);
}
// Check Done permission — contractors can NEVER move to Done
if ($targetColumn['slug'] === 'done') {
if ($user['role'] === 'contractor') {
return Response::json(['error' => 'Only Project Leaders and Admins can mark tasks as Done.'], 403);
}
if (!$this->perms->can($user, 'cards.move_to_done', ['board_id' => $card['board_id']])) {
return Response::json(['error' => 'Permission denied to move to Done'], 403);
}
}
// WIP limit check
if ($targetColumn['wip_limit_total']) {
$currentCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM cards WHERE column_id = ? AND is_archived = 0",
[$targetColumnId]
);
if ($currentCount >= $targetColumn['wip_limit_total']) {
return Response::json(['error' => "WIP limit reached in {$targetColumn['name']}. Move a card out first."], 422);
}
}
if ($targetColumn['wip_limit_per_user']) {
$userCardsInCol = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM cards c JOIN card_assignments ca ON ca.card_id = c.id
WHERE c.column_id = ? AND ca.user_id = ? AND c.is_archived = 0",
[$targetColumnId, $user['id']]
);
if ($userCardsInCol >= $targetColumn['wip_limit_per_user']) {
return Response::json(['error' => "Your WIP limit reached in {$targetColumn['name']}."], 422);
}
}
// Frozen reason required
if ($targetColumn['slug'] === 'frozen' && !$request->input('frozen_reason')) {
return Response::json(['error' => 'Frozen reason is required (min 20 characters).'], 422);
}
$this->db->transaction(function () use ($card, $targetColumn, $request, $user, $cardId) {
$updateData = [
'column_id' => $targetColumn['id'],
'position_in_column' => (int)$request->input('position', 999),
];
// Track frozen time
if ($card['current_slug'] === 'frozen' && $targetColumn['slug'] !== 'frozen') {
// Leaving frozen
if ($card['frozen_started_at']) {
$frozenDuration = time() - strtotime($card['frozen_started_at']);
$updateData['total_frozen_seconds'] = $card['total_frozen_seconds'] + $frozenDuration;
}
$updateData['frozen_started_at'] = null;
$updateData['frozen_reason'] = null;
}
if ($targetColumn['slug'] === 'frozen') {
$updateData['frozen_started_at'] = date('Y-m-d H:i:s');
$updateData['frozen_reason'] = $request->input('frozen_reason');
}
// First doing
if ($targetColumn['slug'] === 'doing' && !$card['first_doing_at']) {
$updateData['first_doing_at'] = date('Y-m-d H:i:s');
}
// Done
if ($targetColumn['slug'] === 'done') {
$updateData['done_at'] = date('Y-m-d H:i:s');
$this->handleCardDone($card, $user);
}
// Reopened from Done
if ($card['current_slug'] === 'done' && $targetColumn['slug'] !== 'done') {
$updateData['done_at'] = null;
}
$this->db->update('cards', $updateData, 'id = ?', [(int)$cardId]);
$this->logCardActivity((int)$cardId, $user['id'], 'moved', [
'from_column' => $card['current_slug'],
'to_column' => $targetColumn['slug'],
]);
});
// Notify assignees + watchers
$this->notifyCardChange((int)$cardId, $user, "Card {$card['card_key']} moved to {$targetColumn['name']}");
$this->events->fire('card.moved', [
'card_id' => (int)$cardId,
'from' => $card['current_slug'],
'to' => $targetColumn['slug'],
'user' => $user,
]);
return Response::json(['success' => true]);
}
public function assign(Request $request, string $cardId): Response
{
$user = $request->user();
if (!$this->perms->can($user, 'cards.assign', ['board_id' => $this->getCardBoardId((int)$cardId)])) {
return Response::json(['error' => 'Permission denied'], 403);
}
$assigneeId = (int)$request->input('user_id');
$action = $request->input('action', 'assign');
if ($action === 'unassign') {
$this->db->delete('card_assignments', 'card_id = ? AND user_id = ?', [(int)$cardId, $assigneeId]);
$this->logCardActivity((int)$cardId, $user['id'], 'unassigned', ['user_id' => $assigneeId]);
$assignee = $this->db->fetchOne("SELECT full_name_en FROM users WHERE id = ?", [$assigneeId]);
$card = $this->db->fetchOne("SELECT card_key, title FROM cards WHERE id = ?", [(int)$cardId]);
$this->notif->createImportant($assigneeId, 'Unassigned from Task',
"You were unassigned from {$card['card_key']}: {$card['title']}");
return Response::json(['success' => true]);
}
$exists = $this->db->fetchOne("SELECT id FROM card_assignments WHERE card_id = ? AND user_id = ?", [(int)$cardId, $assigneeId]);
if ($exists) {
return Response::json(['error' => 'Already assigned'], 422);
}
$this->db->insert('card_assignments', [
'card_id' => (int)$cardId,
'user_id' => $assigneeId,
'assigned_by_id' => $user['id'],
]);
$this->logCardActivity((int)$cardId, $user['id'], 'assigned', ['user_id' => $assigneeId]);
$card = $this->db->fetchOne("SELECT c.card_key, c.title, b.name as board_name FROM cards c JOIN boards b ON b.id = c.board_id WHERE c.id = ?", [(int)$cardId]);
$this->notif->createImportant($assigneeId, 'New Task Assigned',
"You've been assigned to {$card['card_key']}: {$card['title']} on {$card['board_name']}",
"/boards/{$this->getCardBoardId((int)$cardId)}/cards/{$cardId}", 'card', (int)$cardId);
$this->events->fire('card.assigned', ['card_id' => (int)$cardId, 'assignee_id' => $assigneeId, 'user' => $user]);
return Response::json(['success' => true]);
}
public function addComment(Request $request, string $cardId): Response
{
$user = $request->user();
$valid = $this->validator->validate($request->all(), ['content' => 'required|min:1']);
if (!$valid) {
return Response::json(['errors' => $this->validator->errors()], 422);
}
$commentId = $this->db->insert('card_comments', [
'card_id' => (int)$cardId,
'author_id' => $user['id'],
'content' => $request->input('content'),
]);
$this->logCardActivity((int)$cardId, $user['id'], 'comment_added', ['comment_id' => $commentId]);
// Notify assignees + watchers (not the commenter)
$this->notifyCardChange((int)$cardId, $user, "{$user['full_name_en']} commented on the card");
return Response::json(['success' => true, 'id' => $commentId]);
}
public function editComment(Request $request, string $cardId, string $commentId): Response
{
$user = $request->user();
$comment = $this->db->fetchOne("SELECT * FROM card_comments WHERE id = ? AND card_id = ?", [(int)$commentId, (int)$cardId]);
if (!$comment) {
return Response::json(['error' => 'Comment not found'], 404);
}
if ($comment['author_id'] !== $user['id'] && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Can only edit your own comments'], 403);
}
// 15-minute edit window
$editWindow = strtotime($comment['created_at']) + (15 * 60);
if (time() > $editWindow && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Edit window expired (15 minutes)'], 403);
}
$updateData = ['content' => $request->input('content'), 'edited_at' => date('Y-m-d H:i:s')];
if (!$comment['original_content']) {
$updateData['original_content'] = $comment['content'];
}
$this->db->update('card_comments', $updateData, 'id = ?', [(int)$commentId]);
return Response::json(['success' => true]);
}
public function setBounty(Request $request, string $cardId): Response
{
$user = $request->user();
if (!$this->perms->can($user, 'cards.set_bounty')) {
return Response::json(['error' => 'Permission denied'], 403);
}
$amount = (float)$request->input('amount');
if ($amount < 0) {
return Response::json(['error' => 'Invalid bounty amount'], 422);
}
$splitJson = $request->input('bounty_split_json');
$before = $this->db->fetchOne("SELECT bounty_amount, bounty_split_json FROM cards WHERE id = ?", [(int)$cardId]);
$this->db->update('cards', [
'bounty_amount' => $amount ?: null,
'bounty_split_json' => $splitJson ? json_encode($splitJson) : null,
], 'id = ?', [(int)$cardId]);
$this->logCardActivity((int)$cardId, $user['id'], 'bounty_set', [
'old_amount' => $before['bounty_amount'], 'new_amount' => $amount
]);
return Response::json(['success' => true]);
}
public function watch(Request $request, string $cardId): Response
{
$user = $request->user();
$action = $request->input('action', 'watch');
if ($action === 'unwatch') {
$this->db->delete('card_watchers', 'card_id = ? AND user_id = ?', [(int)$cardId, $user['id']]);
} else {
$exists = $this->db->fetchOne("SELECT id FROM card_watchers WHERE card_id = ? AND user_id = ?", [(int)$cardId, $user['id']]);
if (!$exists) {
$this->db->insert('card_watchers', ['card_id' => (int)$cardId, 'user_id' => $user['id']]);
}
}
return Response::json(['success' => true]);
}
public function addLabel(Request $request, string $cardId): Response
{
$user = $request->user();
$labelId = (int)$request->input('label_id');
$exists = $this->db->fetchOne("SELECT card_id FROM card_labels WHERE card_id = ? AND label_id = ?", [(int)$cardId, $labelId]);
if ($exists) {
return Response::json(['error' => 'Label already applied'], 422);
}
$this->db->query("INSERT INTO card_labels (card_id, label_id) VALUES (?, ?)", [(int)$cardId, $labelId]);
$label = $this->db->fetchOne("SELECT text FROM labels WHERE id = ?", [$labelId]);
$this->logCardActivity((int)$cardId, $user['id'], 'label_added', ['label' => $label['text'] ?? '']);
return Response::json(['success' => true]);
}
public function removeLabel(Request $request, string $cardId, string $labelId): Response
{
$this->db->query("DELETE FROM card_labels WHERE card_id = ? AND label_id = ?", [(int)$cardId, (int)$labelId]);
return Response::json(['success' => true]);
}
public function addChecklist(Request $request, string $cardId): Response
{
$maxPos = (int)$this->db->fetchColumn("SELECT COALESCE(MAX(position),0) FROM card_checklists WHERE card_id = ?", [(int)$cardId]);
$id = $this->db->insert('card_checklists', [
'card_id' => (int)$cardId,
'name' => $request->input('name', 'Checklist'),
'position' => $maxPos + 1,
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function addChecklistItem(Request $request, string $cardId, string $checklistId): Response
{
$maxPos = (int)$this->db->fetchColumn("SELECT COALESCE(MAX(position),0) FROM card_checklist_items WHERE checklist_id = ?", [(int)$checklistId]);
$id = $this->db->insert('card_checklist_items', [
'checklist_id' => (int)$checklistId,
'text' => $request->input('text'),
'position' => $maxPos + 1,
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function toggleChecklistItem(Request $request, string $cardId, string $itemId): Response
{
$user = $request->user();
$item = $this->db->fetchOne("SELECT * FROM card_checklist_items WHERE id = ?", [(int)$itemId]);
if (!$item) return Response::json(['error' => 'Item not found'], 404);
$newState = $item['is_checked'] ? 0 : 1;
$this->db->update('card_checklist_items', [
'is_checked' => $newState,
'checked_by_id' => $newState ? $user['id'] : null,
'checked_at' => $newState ? date('Y-m-d H:i:s') : null,
], 'id = ?', [(int)$itemId]);
$action = $newState ? 'checklist_item_completed' : 'checklist_item_unchecked';
$this->logCardActivity((int)$cardId, $user['id'], $action, ['item' => $item['text']]);
return Response::json(['success' => true, 'is_checked' => $newState]);
}
public function archive(Request $request, string $cardId): Response
{
$user = $request->user();
$this->db->update('cards', ['is_archived' => 1, 'archived_at' => date('Y-m-d H:i:s')], 'id = ?', [(int)$cardId]);
$this->logCardActivity((int)$cardId, $user['id'], 'archived', []);
$this->audit->log($user, 'CARD_ARCHIVED', 'card', (int)$cardId, 'cards', "/cards/{$cardId}",
null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true]);
}
public function duplicate(Request $request, string $cardId): Response
{
$user = $request->user();
$card = $this->db->fetchOne("SELECT * FROM cards WHERE id = ?", [(int)$cardId]);
if (!$card) return Response::json(['error' => 'Card not found'], 404);
$targetBoardId = (int)($request->input('target_board_id', $card['board_id']));
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ?", [$targetBoardId]);
if (!$board) return Response::json(['error' => 'Target board not found'], 404);
$backlogCol = $this->db->fetchOne("SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$targetBoardId]);
$newCardId = $this->db->transaction(function () use ($card, $board, $backlogCol, $user, $request) {
$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'];
$newId = $this->db->insert('cards', [
'board_id' => $board['id'],
'column_id' => $backlogCol['id'],
'card_number' => $updated['card_sequence'],
'card_key' => $cardKey,
'title' => 'Copy of ' . $card['title'],
'description' => $card['description'],
'priority' => $card['priority'],
'estimated_hours' => $card['estimated_hours'],
'deadline' => $request->input('keep_deadline') ? $card['deadline'] : null,
'position_in_column' => 0,
'created_by_id' => $user['id'],
]);
// Copy labels
$labels = $this->db->fetchAll("SELECT label_id FROM card_labels WHERE card_id = ?", [$card['id']]);
foreach ($labels as $l) {
$this->db->query("INSERT IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)", [$newId, $l['label_id']]);
}
// Copy checklists
$checklists = $this->db->fetchAll("SELECT * FROM card_checklists WHERE card_id = ?", [$card['id']]);
foreach ($checklists as $cl) {
$newClId = $this->db->insert('card_checklists', [
'card_id' => $newId, 'name' => $cl['name'], 'position' => $cl['position'],
]);
$items = $this->db->fetchAll("SELECT * FROM card_checklist_items WHERE checklist_id = ?", [$cl['id']]);
foreach ($items as $item) {
$this->db->insert('card_checklist_items', [
'checklist_id' => $newClId, 'text' => $item['text'], 'position' => $item['position'],
]);
}
}
$this->logCardActivity($newId, $user['id'], 'created', ['duplicated_from' => $card['card_key']]);
return $newId;
});
return Response::json(['success' => true, 'id' => $newCardId]);
}
private function handleCardDone(array $card, array $user): void
{
if (!$card['bounty_amount'] || $card['bounty_amount'] <= 0) return;
$assignees = $this->db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
if (empty($assignees)) return;
$splitJson = $card['bounty_split_json'] ? json_decode($card['bounty_split_json'], true) : null;
$totalBounty = (float)$card['bounty_amount'];
$month = date('Y-m');
foreach ($assignees as $i => $a) {
$userId = $a['user_id'];
if ($splitJson && isset($splitJson[$userId])) {
$pct = (float)$splitJson[$userId];
} else {
$pct = round(100 / count($assignees), 2);
}
$amount = round($totalBounty * ($pct / 100), 2);
$exists = $this->db->fetchOne(
"SELECT id FROM bounty_payouts WHERE card_id = ? AND recipient_id = ?",
[$card['id'], $userId]
);
if ($exists) continue;
$this->db->insert('bounty_payouts', [
'card_id' => $card['id'],
'recipient_id' => $userId,
'amount' => $amount,
'total_bounty' => $totalBounty,
'split_percentage' => $pct,
'payroll_month' => $month,
]);
$this->notif->createImportant($userId, '💰 Bounty Earned!',
"You earned " . number_format($amount, 2) . " EGP for completing {$card['card_key']}: {$card['title']}",
"/cards/{$card['id']}", 'card', $card['id']);
}
$this->events->fire('bounty.paid', ['card_id' => $card['id'], 'total' => $totalBounty]);
}
private function notifyCardChange(int $cardId, array $actingUser, string $message): void
{
$card = $this->db->fetchOne("SELECT card_key, title FROM cards WHERE id = ?", [$cardId]);
$recipients = $this->db->fetchAll(
"SELECT DISTINCT user_id FROM (
SELECT user_id FROM card_assignments WHERE card_id = ?
UNION SELECT user_id FROM card_watchers WHERE card_id = ?
) u WHERE user_id != ?",
[$cardId, $cardId, $actingUser['id']]
);
foreach ($recipients as $r) {
$this->notif->createInformational($r['user_id'],
"{$card['card_key']}: {$card['title']}", $message);
}
}
private function logCardActivity(int $cardId, int $userId, string $action, array $details): void
{
$this->db->insert('card_activity_log', [
'card_id' => $cardId,
'user_id' => $userId,
'action' => $action,
'details_json' => json_encode($details),
]);
}
private function getChecklists(int $cardId): array
{
$checklists = $this->db->fetchAll("SELECT * FROM card_checklists WHERE card_id = ? ORDER BY position", [$cardId]);
foreach ($checklists as &$cl) {
$cl['items'] = $this->db->fetchAll("SELECT * FROM card_checklist_items WHERE checklist_id = ? ORDER BY position", [$cl['id']]);
}
return $checklists;
}
private function getCardBoardId(int $cardId): ?int
{
$card = $this->db->fetchOne("SELECT board_id FROM cards WHERE id = ?", [$cardId]);
return $card ? (int)$card['board_id'] : null;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->post('/boards/{boardId}/cards', Modules\Cards\Controllers\CardController::class, 'create');
$router->get('/cards/{cardId}', Modules\Cards\Controllers\CardController::class, 'show');
$router->post('/cards/{cardId}', Modules\Cards\Controllers\CardController::class, 'update');
$router->post('/cards/{cardId}/move', Modules\Cards\Controllers\CardController::class, 'move');
$router->post('/cards/{cardId}/assign', Modules\Cards\Controllers\CardController::class, 'assign');
$router->post('/cards/{cardId}/comments', Modules\Cards\Controllers\CardController::class, 'addComment');
$router->post('/cards/{cardId}/comments/{commentId}/edit', Modules\Cards\Controllers\CardController::class, 'editComment');
$router->post('/cards/{cardId}/bounty', Modules\Cards\Controllers\CardController::class, 'setBounty');
$router->post('/cards/{cardId}/watch', Modules\Cards\Controllers\CardController::class, 'watch');
$router->post('/cards/{cardId}/labels', Modules\Cards\Controllers\CardController::class, 'addLabel');
$router->post('/cards/{cardId}/labels/{labelId}/remove', Modules\Cards\Controllers\CardController::class, 'removeLabel');
$router->post('/cards/{cardId}/checklists', Modules\Cards\Controllers\CardController::class, 'addChecklist');
$router->post('/cards/{cardId}/checklists/{checklistId}/items', Modules\Cards\Controllers\CardController::class, 'addChecklistItem');
$router->post('/cards/{cardId}/checklist-items/{itemId}/toggle', Modules\Cards\Controllers\CardController::class, 'toggleChecklistItem');
$router->post('/cards/{cardId}/archive', Modules\Cards\Controllers\CardController::class, 'archive');
$router->post('/cards/{cardId}/duplicate', Modules\Cards\Controllers\CardController::class, 'duplicate');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Deductions\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Validation\Validator;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
final class DeductionController
{
private Connection $db;
private PermissionEngine $perms;
private Validator $validator;
private AuditLogger $audit;
private NotificationManager $notif;
private CalculationEngine $calc;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->validator = $c->resolve(Validator::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->calc = $c->resolve(CalculationEngine::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$month = $request->query('month', date('Y-m'));
$status = $request->query('status');
$contractorId = $request->query('contractor_id');
$sql = "SELECT d.*, u.full_name_en as contractor_name, i.full_name_en as initiated_by_name
FROM deductions d
JOIN users u ON u.id = d.contractor_id
JOIN users i ON i.id = d.initiated_by_id
WHERE d.deleted_at IS NULL";
$params = [];
if ($user['role'] === 'contractor') {
$sql .= " AND d.contractor_id = ?";
$params[] = $user['id'];
} elseif ($user['role'] === 'project_leader') {
$sql .= " AND d.contractor_id IN (
SELECT bm2.user_id FROM board_members bm1
JOIN board_members bm2 ON bm2.board_id = bm1.board_id WHERE bm1.user_id = ?)";
$params[] = $user['id'];
}
if ($month) {
$sql .= " AND d.payroll_month = ?";
$params[] = $month;
}
if ($status) {
$sql .= " AND d.status = ?";
$params[] = $status;
}
if ($contractorId && $user['role'] !== 'contractor') {
$sql .= " AND d.contractor_id = ?";
$params[] = (int)$contractorId;
}
$sql .= " ORDER BY d.created_at DESC LIMIT 100";
$deductions = $this->db->fetchAll($sql, $params);
return Response::json(['deductions' => $deductions, 'month' => $month]);
}
public function show(Request $request, string $deductionId): Response
{
$deduction = $this->db->fetchOne(
"SELECT d.*, u.full_name_en as contractor_name, i.full_name_en as initiated_by_name,
r.full_name_en as reviewer_name
FROM deductions d
JOIN users u ON u.id = d.contractor_id
JOIN users i ON i.id = d.initiated_by_id
LEFT JOIN users r ON r.id = d.reviewed_by_id
WHERE d.id = ? AND d.deleted_at IS NULL",
[(int)$deductionId]
);
if (!$deduction) return Response::json(['error' => 'Not found'], 404);
$deduction['responses'] = $this->db->fetchAll(
"SELECT * FROM deduction_responses WHERE deduction_id = ? ORDER BY created_at",
[(int)$deductionId]
);
$deduction['evidence'] = $this->db->fetchAll(
"SELECT de.*, f.original_name, f.mime_type FROM deduction_evidence de
JOIN file_uploads f ON f.id = de.file_id WHERE de.deduction_id = ?",
[(int)$deductionId]
);
return Response::json(['deduction' => $deduction]);
}
public function initiate(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'deductions.initiate');
$valid = $this->validator->validate($request->all(), [
'contractor_id' => 'required|integer',
'category' => 'required|in:A,B,C,D',
'sub_category' => 'required|min:2|max:5',
'violation_date' => 'required|date',
'description' => 'required|min:20',
]);
if (!$valid) return Response::json(['errors' => $this->validator->errors()], 422);
$contractorId = (int)$request->input('contractor_id');
$contractor = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$contractorId]);
if (!$contractor) return Response::json(['error' => 'Contractor not found'], 404);
// Calculate amount
$actualSalary = (float)($contractor['actual_salary'] ?? 0);
$expectedDays = $this->calc->calculate('expected_working_days', [
'user_id' => $contractorId, 'month' => date('Y-m'),
]);
$dailyRate = $expectedDays > 0 ? round($actualSalary / $expectedDays, 2) : 0;
$calculatedAmount = $this->calculateDeductionAmount(
$request->input('category'), $request->input('sub_category'),
$dailyRate, $actualSalary, $request->all()
);
$overrideAmount = $request->input('calculated_amount');
if ($overrideAmount !== null) {
$calculatedAmount = (float)$overrideAmount;
}
$isPLDraft = $user['role'] === 'project_leader';
$status = $isPLDraft ? 'draft_pending_admin' : 'pending_acknowledgment';
$deductionId = $this->db->insert('deductions', [
'contractor_id' => $contractorId,
'initiated_by_id' => $user['id'],
'category' => $request->input('category'),
'sub_category' => $request->input('sub_category'),
'related_card_id' => $request->input('related_card_id') ?: null,
'related_report_id' => $request->input('related_report_id') ?: null,
'violation_date' => $request->input('violation_date'),
'description' => $request->input('description'),
'calculated_amount' => $calculatedAmount,
'status' => $status,
'payroll_month' => date('Y-m'),
'is_auto_triggered' => (int)($request->input('is_auto', false)),
]);
if ($isPLDraft) {
$admins = $this->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'], 'Deduction Draft for Review',
"PL {$user['full_name_en']} initiated a deduction for {$contractor['full_name_en']}.",
"/deductions/{$deductionId}", 'deduction', $deductionId);
}
} else {
$this->notif->createBlocking($contractorId, 'Deduction Issued',
"A deduction of " . number_format($calculatedAmount, 2) . " EGP ({$request->input('category')}{$request->input('sub_category')}) has been issued. Reason: {$request->input('description')}",
"/deductions/{$deductionId}", 'deduction', $deductionId);
}
$this->audit->log($user, 'DEDUCTION_INITIATED', 'deduction', $deductionId, 'deductions', '/deductions',
null, ['amount' => $calculatedAmount, 'category' => $request->input('category')],
$request->ip(), $request->userAgent());
return Response::json(['success' => true, 'id' => $deductionId, 'calculated_amount' => $calculatedAmount]);
}
public function acknowledge(Request $request, string $deductionId): Response
{
$user = $request->user();
$deduction = $this->db->fetchOne("SELECT * FROM deductions WHERE id = ? AND contractor_id = ?", [(int)$deductionId, $user['id']]);
if (!$deduction) return Response::json(['error' => 'Not found'], 404);
if ($deduction['status'] !== 'pending_acknowledgment') return Response::json(['error' => 'Cannot acknowledge at this stage'], 422);
$responseDeadline = date('Y-m-d H:i:s', strtotime('+48 hours'));
$this->db->update('deductions', [
'status' => 'acknowledged',
'acknowledged_at' => date('Y-m-d H:i:s'),
'response_deadline' => $responseDeadline,
], 'id = ?', [(int)$deductionId]);
return Response::json(['success' => true, 'response_deadline' => $responseDeadline]);
}
public function respond(Request $request, string $deductionId): Response
{
$user = $request->user();
$deduction = $this->db->fetchOne("SELECT * FROM deductions WHERE id = ? AND contractor_id = ?", [(int)$deductionId, $user['id']]);
if (!$deduction) return Response::json(['error' => 'Not found'], 404);
if ($deduction['status'] !== 'acknowledged') return Response::json(['error' => 'Cannot respond at this stage'], 422);
$responseType = $request->input('response_type');
if (!in_array($responseType, ['accept', 'dispute'])) return Response::json(['error' => 'Invalid response type'], 422);
$this->db->insert('deduction_responses', [
'deduction_id' => (int)$deductionId,
'response_type' => $responseType,
'explanation' => $request->input('explanation'),
]);
if ($responseType === 'accept') {
$this->db->update('deductions', [
'status' => 'accepted',
'final_amount' => $deduction['calculated_amount'],
'applied_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$deductionId]);
$this->checkDeductionThreshold($deduction['contractor_id']);
} else {
$this->db->update('deductions', ['status' => 'disputed'], 'id = ?', [(int)$deductionId]);
$admins = $this->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'], 'Deduction Disputed',
"Contractor disputed deduction #{$deductionId}. Review required.",
"/deductions/{$deductionId}", 'deduction', (int)$deductionId);
}
}
return Response::json(['success' => true]);
}
public function reviewDecision(Request $request, string $deductionId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'deductions.review');
$deduction = $this->db->fetchOne("SELECT * FROM deductions WHERE id = ?", [(int)$deductionId]);
if (!$deduction) return Response::json(['error' => 'Not found'], 404);
$decision = $request->input('decision');
if (!in_array($decision, ['upheld', 'reduced', 'dismissed'])) {
return Response::json(['error' => 'Invalid decision'], 422);
}
$updateData = [
'reviewed_by_id' => $user['id'],
'review_decision' => $decision,
'review_note' => $request->input('review_note'),
'reviewed_at' => date('Y-m-d H:i:s'),
];
switch ($decision) {
case 'upheld':
$updateData['status'] = 'applied';
$updateData['final_amount'] = $deduction['calculated_amount'];
$updateData['applied_at'] = date('Y-m-d H:i:s');
break;
case 'reduced':
$newAmount = (float)$request->input('final_amount', $deduction['calculated_amount']);
$updateData['status'] = 'reduced';
$updateData['final_amount'] = $newAmount;
$updateData['applied_at'] = date('Y-m-d H:i:s');
break;
case 'dismissed':
$updateData['status'] = 'dismissed';
$updateData['final_amount'] = 0;
break;
}
$this->db->update('deductions', $updateData, 'id = ?', [(int)$deductionId]);
$statusLabel = ucfirst($decision);
$this->notif->createImportant($deduction['contractor_id'], "Deduction {$statusLabel}",
"Your dispute for deduction #{$deductionId} was {$decision}.",
"/deductions/{$deductionId}", 'deduction', (int)$deductionId);
if (in_array($decision, ['upheld', 'reduced'])) {
$this->checkDeductionThreshold($deduction['contractor_id']);
}
return Response::json(['success' => true]);
}
public function adminReview(Request $request, string $deductionId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'deductions.review');
$deduction = $this->db->fetchOne("SELECT * FROM deductions WHERE id = ? AND status = 'draft_pending_admin'", [(int)$deductionId]);
if (!$deduction) return Response::json(['error' => 'Not found or not in draft'], 404);
$action = $request->input('action');
if ($action === 'approve') {
$this->db->update('deductions', ['status' => 'pending_acknowledgment'], 'id = ?', [(int)$deductionId]);
$this->notif->createBlocking($deduction['contractor_id'], 'Deduction Issued',
"A deduction of " . number_format($deduction['calculated_amount'], 2) . " EGP has been issued.",
"/deductions/{$deductionId}", 'deduction', (int)$deductionId);
} elseif ($action === 'reject') {
$this->db->update('deductions', ['status' => 'dismissed', 'review_note' => $request->input('reason')], 'id = ?', [(int)$deductionId]);
$this->notif->createImportant($deduction['initiated_by_id'], 'Deduction Draft Rejected',
"Your deduction draft #{$deductionId} was rejected.");
}
return Response::json(['success' => true]);
}
private function calculateDeductionAmount(string $cat, string $sub, float $dailyRate, float $actualSalary, array $context): float
{
$daysLate = (int)($context['days_late'] ?? 1);
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),
'B1' => round($dailyRate * 0.02, 2),
'B2' => round($dailyRate, 2),
'B3' => round($actualSalary * 0.05, 2),
'B4' => round($actualSalary * 0.25, 2),
'C1' => round($dailyRate * 0.03, 2),
'C2' => round($dailyRate * 0.10, 2),
'C3' => round($dailyRate * 0.25, 2),
'C4' => round($actualSalary * 0.15, 2),
'D1' => round($dailyRate * 0.02, 2),
'D2' => round($dailyRate * 0.05, 2),
'D3' => round($actualSalary * 0.15, 2),
'D4' => round($actualSalary * 0.10, 2),
default => 0,
};
}
private function checkDeductionThreshold(int $contractorId): void
{
$month = date('Y-m');
$contractor = $this->db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$contractorId]);
if (!$contractor || !$contractor['actual_salary']) return;
$totalDeductions = (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",
[$contractorId, $month]
);
$threshold = ($contractor['actual_salary'] * 0.40);
if ($totalDeductions >= $threshold) {
$this->notif->createBlocking($contractorId, 'Critical Deduction Threshold',
'Your deductions have reached the critical threshold (40% of salary).');
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], '40% Deduction Threshold Reached',
"Contractor ID {$contractorId} has reached the 40% deduction threshold. PIP recommended.",
"/users/{$contractorId}", 'user', $contractorId);
}
}
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/deductions',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Deductions\Controllers\DeductionController::class, 'index');
$router->get('/{deductionId}', Modules\Deductions\Controllers\DeductionController::class, 'show');
$router->post('/', Modules\Deductions\Controllers\DeductionController::class, 'initiate');
$router->post('/{deductionId}/acknowledge', Modules\Deductions\Controllers\DeductionController::class, 'acknowledge');
$router->post('/{deductionId}/respond', Modules\Deductions\Controllers\DeductionController::class, 'respond');
$router->post('/{deductionId}/review-decision', Modules\Deductions\Controllers\DeductionController::class, 'reviewDecision');
$router->post('/{deductionId}/admin-review', Modules\Deductions\Controllers\DeductionController::class, 'adminReview');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Holidays\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
final class HolidayController
{
private Connection $db;
private PermissionEngine $perms;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
}
public function index(Request $request): Response
{
$year = $request->query('year', date('Y'));
$holidays = $this->db->fetchAll(
"SELECT * FROM holidays WHERE YEAR(start_date) = ? OR is_recurring = 1 ORDER BY start_date",
[(int)$year]
);
return Response::json(['holidays' => $holidays]);
}
public function create(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'holidays.manage');
$id = $this->db->insert('holidays', [
'name' => $request->input('name'),
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date'),
'is_recurring' => (int)($request->input('is_recurring', 0)),
'notes' => $request->input('notes'),
'created_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function update(Request $request, string $id): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'holidays.manage');
$data = $request->only(['name', 'start_date', 'end_date', 'is_recurring', 'notes']);
$data = array_filter($data, fn($v) => $v !== null);
if (isset($data['is_recurring'])) $data['is_recurring'] = (int)$data['is_recurring'];
$this->db->update('holidays', $data, 'id = ?', [(int)$id]);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $id): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'holidays.manage');
$this->db->delete('holidays', 'id = ?', [(int)$id]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$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
<?php
declare(strict_types=1);
namespace Modules\Labels\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
final class LabelController
{
private Connection $db;
private PermissionEngine $perms;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
}
public function index(Request $request): Response
{
$boardId = $request->query('board_id');
$sql = "SELECT * FROM labels WHERE scope = 'organization'";
$params = [];
if ($boardId) {
$sql .= " OR (scope = 'board' AND board_id = ?)";
$params[] = (int)$boardId;
}
$sql .= " ORDER BY scope DESC, text";
return Response::json(['labels' => $this->db->fetchAll($sql, $params)]);
}
public function create(Request $request): Response
{
$user = $request->user();
$scope = $request->input('scope', 'organization');
$boardId = $request->input('board_id');
if ($scope === 'organization') {
$this->perms->denyUnlessAllowed($user, 'labels.create.org');
} else {
$this->perms->denyUnlessAllowed($user, 'labels.create.board', ['board_id' => (int)$boardId]);
}
$id = $this->db->insert('labels', [
'text' => substr($request->input('text', ''), 0, 20),
'bg_color' => $request->input('bg_color', '#6B7280'),
'text_color' => $request->input('text_color', '#FFFFFF'),
'scope' => $scope,
'board_id' => $scope === 'board' ? (int)$boardId : null,
'created_by_id' => $user['id'],
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function update(Request $request, string $labelId): Response
{
$user = $request->user();
$label = $this->db->fetchOne("SELECT * FROM labels WHERE id = ?", [(int)$labelId]);
if (!$label) return Response::json(['error' => 'Not found'], 404);
$data = $request->only(['text', 'bg_color', 'text_color']);
$data = array_filter($data, fn($v) => $v !== null);
if (isset($data['text'])) $data['text'] = substr($data['text'], 0, 20);
$this->db->update('labels', $data, 'id = ?', [(int)$labelId]);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $labelId): Response
{
$user = $request->user();
$label = $this->db->fetchOne("SELECT * FROM labels WHERE id = ?", [(int)$labelId]);
if (!$label) return Response::json(['error' => 'Not found'], 404);
$this->db->query("DELETE FROM card_labels WHERE label_id = ?", [(int)$labelId]);
$this->db->delete('labels', 'id = ?', [(int)$labelId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/api/labels',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Labels\Controllers\LabelController::class, 'index');
$router->post('/', Modules\Labels\Controllers\LabelController::class, 'create');
$router->post('/{labelId}', Modules\Labels\Controllers\LabelController::class, 'update');
$router->post('/{labelId}/delete', Modules\Labels\Controllers\LabelController::class, 'delete');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Messaging\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
use Engine\Template\TemplateEngine;
final class MessagingController
{
private Connection $db;
private NotificationManager $notif;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function conversations(Request $request): Response
{
$user = $request->user();
$conversations = $this->db->fetchAll(
"SELECT c.*, cp.last_read_at,
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) as last_message,
(SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id AND m.deleted_at IS NULL AND m.created_at > COALESCE(cp.last_read_at, '1970-01-01')) as unread_count
FROM conversations c
JOIN conversation_participants cp ON cp.conversation_id = c.id AND cp.user_id = ?
ORDER BY c.last_message_at DESC NULLS LAST",
[$user['id']]
);
foreach ($conversations as &$conv) {
$conv['participants'] = $this->db->fetchAll(
"SELECT u.id, u.full_name_en, u.username, u.profile_photo_id
FROM users u JOIN conversation_participants cp ON cp.user_id = u.id
WHERE cp.conversation_id = ? AND u.id != ?",
[$conv['id'], $user['id']]
);
}
if ($request->wantsJson()) {
return Response::json(['conversations' => $conversations]);
}
return Response::html($this->templates->render('messaging/index', [
'user' => $user, 'conversations' => $conversations,
]));
}
public function messages(Request $request, string $conversationId): Response
{
$user = $request->user();
// Verify participant
$participant = $this->db->fetchOne(
"SELECT id FROM conversation_participants WHERE conversation_id = ? AND user_id = ?",
[(int)$conversationId, $user['id']]
);
if (!$participant && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Not a participant'], 403);
}
$messages = $this->db->fetchAll(
"SELECT m.*, u.full_name_en as sender_name, u.profile_photo_id as sender_photo
FROM messages m JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = ? AND m.deleted_at IS NULL ORDER BY m.created_at ASC",
[(int)$conversationId]
);
// Mark as read
$this->db->update('conversation_participants', [
'last_read_at' => date('Y-m-d H:i:s'),
], 'conversation_id = ? AND user_id = ?', [(int)$conversationId, $user['id']]);
return Response::json(['messages' => $messages]);
}
public function startConversation(Request $request): Response
{
$user = $request->user();
$recipientIds = $request->input('recipient_ids', []);
if (empty($recipientIds)) {
return Response::json(['error' => 'At least one recipient required'], 422);
}
// Messaging permission enforcement
if ($user['role'] === 'contractor') {
foreach ($recipientIds as $rid) {
$recipient = $this->db->fetchOne("SELECT role FROM users WHERE id = ?", [(int)$rid]);
if (!$recipient || $recipient['role'] === 'contractor') {
return Response::json(['error' => 'Contractors can only message their PL, Admins, and Super Admin.'], 403);
}
}
}
$type = count($recipientIds) > 1 ? 'group' : 'dm';
// Check for existing DM
if ($type === 'dm') {
$existingConv = $this->db->fetchOne(
"SELECT c.id FROM conversations c
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = ?
JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = ?
WHERE c.type = 'dm'",
[$user['id'], (int)$recipientIds[0]]
);
if ($existingConv) {
return Response::json(['success' => true, 'conversation_id' => $existingConv['id'], 'existing' => true]);
}
}
$convId = $this->db->transaction(function () use ($user, $recipientIds, $type, $request) {
$convId = $this->db->insert('conversations', [
'type' => $type,
'title' => $type === 'group' ? $request->input('title') : null,
'created_by_id' => $user['id'],
]);
$this->db->insert('conversation_participants', [
'conversation_id' => $convId,
'user_id' => $user['id'],
]);
foreach ($recipientIds as $rid) {
$this->db->insert('conversation_participants', [
'conversation_id' => $convId,
'user_id' => (int)$rid,
]);
}
return $convId;
});
return Response::json(['success' => true, 'conversation_id' => $convId]);
}
public function sendMessage(Request $request, string $conversationId): Response
{
$user = $request->user();
$participant = $this->db->fetchOne(
"SELECT id FROM conversation_participants WHERE conversation_id = ? AND user_id = ?",
[(int)$conversationId, $user['id']]
);
if (!$participant) return Response::json(['error' => 'Not a participant'], 403);
$content = trim($request->input('content', ''));
if (!$content) return Response::json(['error' => 'Message cannot be empty'], 422);
$messageId = $this->db->insert('messages', [
'conversation_id' => (int)$conversationId,
'sender_id' => $user['id'],
'content' => $content,
]);
$this->db->update('conversations', [
'last_message_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$conversationId]);
// Notify other participants
$others = $this->db->fetchAll(
"SELECT user_id FROM conversation_participants WHERE conversation_id = ? AND user_id != ?",
[(int)$conversationId, $user['id']]
);
foreach ($others as $other) {
$this->notif->createImportant($other['user_id'], 'New Message',
"{$user['full_name_en']} sent you a message.",
"/messages/{$conversationId}", 'conversation', (int)$conversationId);
}
return Response::json(['success' => true, 'id' => $messageId]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/messages',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class, Middleware\BlockingNotificationMiddleware::class]
], function ($router) {
$router->get('/', Modules\Messaging\Controllers\MessagingController::class, 'conversations');
$router->post('/', Modules\Messaging\Controllers\MessagingController::class, 'startConversation');
$router->get('/{conversationId}', Modules\Messaging\Controllers\MessagingController::class, 'messages');
$router->post('/{conversationId}', Modules\Messaging\Controllers\MessagingController::class, 'sendMessage');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Payroll\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
final class PayrollController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$month = $request->query('month', date('Y-m'));
if ($user['role'] === 'contractor') {
$records = $this->db->fetchAll(
"SELECT * FROM payroll_records WHERE contractor_id = ? ORDER BY month DESC",
[$user['id']]
);
} else {
$this->perms->denyUnlessAllowed($user, 'payroll.view.any');
$records = $this->db->fetchAll(
"SELECT pr.*, u.full_name_en FROM payroll_records pr
JOIN users u ON u.id = pr.contractor_id WHERE pr.month = ? ORDER BY u.full_name_en",
[$month]
);
}
return Response::json(['records' => $records, 'month' => $month]);
}
public function calculate(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'payroll.calculate');
$month = $request->input('month', date('Y-m'));
$contractors = $this->db->fetchAll(
"SELECT * FROM users WHERE role = 'contractor' AND status IN ('active','on_pip','suspended') AND actual_salary IS NOT NULL"
);
$count = 0;
foreach ($contractors as $contractor) {
$exists = $this->db->fetchOne(
"SELECT id FROM payroll_records WHERE contractor_id = ? AND month = ?",
[$contractor['id'], $month]
);
if ($exists) continue;
$actual = (float)$contractor['actual_salary'];
$bounties = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$contractor['id'], $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",
[$contractor['id'], $month]
);
$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",
[$contractor['id'], $month]
);
$deductionsByCategory = [];
foreach (['A', 'B', 'C', 'D'] as $cat) {
$deductionsByCategory[$cat] = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ? AND category = ?
AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$contractor['id'], $month, $cat]
);
}
$totalDeductions = array_sum($deductionsByCategory);
$net = $actual + $bounties + $posAdj - $totalDeductions - $negAdj;
$this->db->insert('payroll_records', [
'contractor_id' => $contractor['id'],
'month' => $month,
'actual_salary' => $actual,
'total_bounties' => $bounties,
'total_positive_adjustments' => $posAdj,
'total_deductions_a' => $deductionsByCategory['A'],
'total_deductions_b' => $deductionsByCategory['B'],
'total_deductions_c' => $deductionsByCategory['C'],
'total_deductions_d' => $deductionsByCategory['D'],
'total_negative_adjustments' => $negAdj,
'net_payable' => $net,
'status' => 'calculated',
'calculated_at' => date('Y-m-d H:i:s'),
]);
$count++;
}
return Response::json(['success' => true, 'calculated' => $count, 'month' => $month]);
}
public function submit(Request $request, string $payrollId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'payroll.review');
$this->db->update('payroll_records', [
'status' => 'submitted',
'reviewed_by_id' => $user['id'],
'submitted_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$payrollId]);
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], 'Payroll Submitted for Approval',
"Payroll record #{$payrollId} submitted for approval.");
}
return Response::json(['success' => true]);
}
public function approve(Request $request, string $payrollId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'payroll.approve');
$record = $this->db->fetchOne("SELECT * FROM payroll_records WHERE id = ?", [(int)$payrollId]);
if (!$record) return Response::json(['error' => 'Not found'], 404);
$this->db->update('payroll_records', [
'status' => 'approved',
'approved_by_id' => $user['id'],
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$payrollId]);
$this->notif->createImportant($record['contractor_id'], 'Payment Approved',
"Your payment for {$record['month']} has been approved: " . number_format($record['net_payable'], 2) . " EGP.");
return Response::json(['success' => true]);
}
public function reject(Request $request, string $payrollId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'payroll.approve');
$this->db->update('payroll_records', [
'status' => 'rejected',
'rejected_note' => $request->input('reason', ''),
], 'id = ?', [(int)$payrollId]);
return Response::json(['success' => true]);
}
public function markPaid(Request $request, string $payrollId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'payroll.approve');
$this->db->update('payroll_records', [
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$payrollId]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/payroll',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Payroll\Controllers\PayrollController::class, 'index');
$router->post('/calculate', Modules\Payroll\Controllers\PayrollController::class, 'calculate');
$router->post('/{payrollId}/submit', Modules\Payroll\Controllers\PayrollController::class, 'submit');
$router->post('/{payrollId}/approve', Modules\Payroll\Controllers\PayrollController::class, 'approve');
$router->post('/{payrollId}/reject', Modules\Payroll\Controllers\PayrollController::class, 'reject');
$router->post('/{payrollId}/paid', Modules\Payroll\Controllers\PayrollController::class, 'markPaid');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Reports\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Validation\Validator;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
use Engine\Template\TemplateEngine;
final class ReportController
{
private Connection $db;
private PermissionEngine $perms;
private Validator $validator;
private AuditLogger $audit;
private NotificationManager $notif;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->validator = $c->resolve(Validator::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function submitForm(Request $request): Response
{
$user = $request->user();
$today = date('Y-m-d');
$existingReport = $this->db->fetchOne(
"SELECT * FROM daily_reports WHERE user_id = ? AND report_date = ?",
[$user['id'], $today]
);
$myCards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.title, b.name as board_name FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id JOIN boards b ON b.id = c.board_id
WHERE ca.user_id = ? AND c.is_archived = 0 AND c.done_at IS NULL ORDER BY c.card_key",
[$user['id']]
);
return Response::html($this->templates->render('reports/submit', [
'user' => $user,
'report' => $existingReport,
'my_cards' => $myCards,
'report_date' => $today,
]));
}
public function submit(Request $request): Response
{
$user = $request->user();
$reportDate = $request->input('report_date', date('Y-m-d'));
// Check if already submitted
$existing = $this->db->fetchOne(
"SELECT * FROM daily_reports WHERE user_id = ? AND report_date = ? AND status NOT IN ('draft','unreported')",
[$user['id'], $reportDate]
);
if ($existing) {
return Response::json(['error' => 'Report already submitted for this date.'], 422);
}
$tasks = $request->input('tasks', []);
if (empty($tasks)) {
return Response::json(['error' => 'At least one task entry is required.'], 422);
}
// Determine on-time status
$deadlineSetting = $this->db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'report_deadline_time'");
$deadlineTime = $deadlineSetting ? $deadlineSetting['value'] : '23:59';
$deadline = $reportDate . ' ' . $deadlineTime . ':00';
$isOnTime = time() <= strtotime($deadline);
$graceSetting = $this->db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'report_grace_hours'");
$graceHours = $graceSetting ? (int)$graceSetting['value'] : 24;
$graceDeadline = strtotime($deadline) + ($graceHours * 3600);
$isLate = !$isOnTime && time() <= $graceDeadline;
if (!$isOnTime && !$isLate) {
return Response::json(['error' => 'Grace period has expired for this date.'], 422);
}
$status = $isOnTime ? 'submitted' : 'late';
$totalMinutes = 0;
foreach ($tasks as $t) {
$totalMinutes += (int)($t['time_spent_minutes'] ?? 0);
}
$reportId = $this->db->transaction(function () use ($user, $reportDate, $status, $tasks, $request, $totalMinutes, $isOnTime) {
// Upsert report
$existingDraft = $this->db->fetchOne(
"SELECT id FROM daily_reports WHERE user_id = ? AND report_date = ?",
[$user['id'], $reportDate]
);
$reportData = [
'user_id' => $user['id'],
'report_date' => $reportDate,
'status' => $status,
'blockers' => $request->input('blockers'),
'additional_notes' => $request->input('additional_notes'),
'mood' => $request->input('mood'),
'total_hours' => round($totalMinutes / 60, 2),
'submitted_at' => date('Y-m-d H:i:s'),
'is_on_time' => $isOnTime ? 1 : 0,
];
if ($existingDraft) {
$this->db->update('daily_reports', $reportData, 'id = ?', [$existingDraft['id']]);
$reportId = $existingDraft['id'];
$this->db->delete('report_task_entries', 'report_id = ?', [$reportId]);
} else {
$reportId = $this->db->insert('daily_reports', $reportData);
}
foreach ($tasks as $task) {
$this->db->insert('report_task_entries', [
'report_id' => $reportId,
'card_id' => ($task['card_id'] ?? null) ?: null,
'work_description' => $task['work_description'] ?? '',
'time_spent_minutes' => (int)($task['time_spent_minutes'] ?? 0),
'task_status' => $task['task_status'] ?? 'in_progress',
]);
}
// Check auto-approval
$autoApproval = $this->db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'auto_approval_enabled'");
if ($autoApproval && $autoApproval['value'] === '1' && $isOnTime) {
$canAutoApprove = count($tasks) >= 1 && $totalMinutes >= 60 && $totalMinutes <= 720;
$hasLinkedCard = false;
foreach ($tasks as $t) {
if (!empty($t['card_id'])) $hasLinkedCard = true;
if (strlen($t['work_description'] ?? '') < 50) { $canAutoApprove = false; break; }
}
if ($canAutoApprove && $hasLinkedCard) {
$this->db->update('daily_reports', [
'status' => 'approved_auto',
'reviewed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$reportId]);
}
}
return $reportId;
});
$this->audit->log($user, 'REPORT_SUBMITTED', 'daily_report', $reportId, 'reports', '/reports/submit',
null, ['date' => $reportDate, 'status' => $status], $request->ip(), $request->userAgent());
if ($request->wantsJson()) {
return Response::json(['success' => true, 'id' => $reportId, 'redirect' => '/dashboard']);
}
return Response::redirect('/dashboard');
}
public function review(Request $request): Response
{
$user = $request->user();
if (!$this->perms->can($user, 'reports.review')) {
return Response::json(['error' => 'Forbidden'], 403);
}
$date = $request->query('date', date('Y-m-d'));
$status = $request->query('status');
$sql = "SELECT dr.*, u.full_name_en, u.username
FROM daily_reports dr JOIN users u ON u.id = dr.user_id
WHERE dr.report_date = ?";
$params = [$date];
if ($user['role'] === 'project_leader') {
$sql .= " AND dr.user_id IN (
SELECT bm2.user_id FROM board_members bm1
JOIN board_members bm2 ON bm2.board_id = bm1.board_id
WHERE bm1.user_id = ? AND bm2.user_id != ?)";
$params[] = $user['id'];
$params[] = $user['id'];
}
if ($status) {
$sql .= " AND dr.status = ?";
$params[] = $status;
}
$sql .= " ORDER BY u.full_name_en";
$reports = $this->db->fetchAll($sql, $params);
foreach ($reports as &$r) {
$r['tasks'] = $this->db->fetchAll(
"SELECT rte.*, c.card_key, c.title as card_title FROM report_task_entries rte
LEFT JOIN cards c ON c.id = rte.card_id WHERE rte.report_id = ?",
[$r['id']]
);
}
if ($request->wantsJson()) {
return Response::json(['reports' => $reports, 'date' => $date]);
}
return Response::html($this->templates->render('reports/review', [
'user' => $user, 'reports' => $reports, 'date' => $date,
]));
}
public function reviewAction(Request $request, string $reportId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'reports.review');
$action = $request->input('action');
$note = $request->input('review_note', '');
$report = $this->db->fetchOne("SELECT * FROM daily_reports WHERE id = ?", [(int)$reportId]);
if (!$report) return Response::json(['error' => 'Report not found'], 404);
$statusMap = [
'approve' => 'approved',
'flag_vague' => 'flagged_vague',
'flag_inconsistent' => 'flagged_inconsistent',
'request_revision' => 'revision_requested',
];
$newStatus = $statusMap[$action] ?? null;
if (!$newStatus) return Response::json(['error' => 'Invalid action'], 422);
$this->db->update('daily_reports', [
'status' => $newStatus,
'reviewed_by_id' => $user['id'],
'reviewed_at' => date('Y-m-d H:i:s'),
'review_note' => $note ?: null,
], 'id = ?', [(int)$reportId]);
// Notify contractor
if ($newStatus === 'flagged_vague') {
$this->notif->createImportant($report['user_id'], 'Report Flagged',
"Your report for {$report['report_date']} was flagged as vague. Please add more detail.");
// Check B3 threshold
$vagueCount = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND status = 'flagged_vague'
AND report_date LIKE ?",
[$report['user_id'], date('Y-m') . '%']
);
if ($vagueCount >= 3) {
// Auto-trigger B3 would be handled by event system in full implementation
}
} elseif ($newStatus === 'revision_requested') {
$this->notif->createImportant($report['user_id'], 'Report Revision Requested',
"Please revise your report for {$report['report_date']}. Reason: {$note}");
}
return Response::json(['success' => true, 'new_status' => $newStatus]);
}
public function bulkApprove(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'reports.bulk_approve');
$ids = $request->input('report_ids', []);
if (empty($ids)) return Response::json(['error' => 'No reports selected'], 422);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$this->db->query(
"UPDATE daily_reports SET status = 'approved', reviewed_by_id = ?, reviewed_at = NOW()
WHERE id IN ({$placeholders}) AND status IN ('submitted','late')",
array_merge([$user['id']], $ids)
);
return Response::json(['success' => true, 'count' => count($ids)]);
}
public function history(Request $request): Response
{
$user = $request->user();
$targetUserId = (int)($request->query('user_id', $user['id']));
if ($targetUserId !== $user['id'] && !$this->perms->can($user, 'reports.view.any')) {
if ($user['role'] === 'project_leader') {
$this->perms->denyUnlessAllowed($user, 'reports.view.own_team', ['target_user_id' => $targetUserId]);
} else {
return Response::json(['error' => 'Forbidden'], 403);
}
}
$month = $request->query('month', date('Y-m'));
$reports = $this->db->fetchAll(
"SELECT dr.*, u.full_name_en as reviewer_name FROM daily_reports dr
LEFT JOIN users u ON u.id = dr.reviewed_by_id
WHERE dr.user_id = ? AND dr.report_date LIKE ? ORDER BY dr.report_date DESC",
[$targetUserId, $month . '%']
);
return Response::json(['reports' => $reports, 'month' => $month]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/reports',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->get('/submit', Modules\Reports\Controllers\ReportController::class, 'submitForm');
$router->post('/submit', Modules\Reports\Controllers\ReportController::class, 'submit');
$router->get('/review', Modules\Reports\Controllers\ReportController::class, 'review');
$router->post('/{reportId}/review', Modules\Reports\Controllers\ReportController::class, 'reviewAction');
$router->post('/bulk-approve', Modules\Reports\Controllers\ReportController::class, 'bulkApprove');
$router->get('/history', Modules\Reports\Controllers\ReportController::class, 'history');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Settings\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
final class SettingsController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
}
public function index(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'settings.manage');
$settings = $this->db->fetchAll("SELECT * FROM system_settings ORDER BY `group`, `key`");
$grouped = [];
foreach ($settings as $s) {
$grouped[$s['group']][] = $s;
}
return Response::json(['settings' => $grouped]);
}
public function update(Request $request): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'settings.manage');
$updates = $request->input('settings', []);
foreach ($updates as $key => $value) {
$existing = $this->db->fetchOne("SELECT * FROM system_settings WHERE `key` = ?", [$key]);
if (!$existing) continue;
$oldValue = $existing['value'];
$this->db->update('system_settings', [
'value' => (string)$value,
'updated_by_id' => $user['id'],
], '`key` = ?', [$key]);
$this->audit->log($user, 'SETTING_CHANGED', 'system_setting', null, 'settings', '/settings',
['key' => $key, 'value' => $oldValue], ['key' => $key, 'value' => (string)$value],
$request->ip(), $request->userAgent());
}
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/settings',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Settings\Controllers\SettingsController::class, 'index');
$router->post('/', Modules\Settings\Controllers\SettingsController::class, 'update');
});
\ No newline at end of file
<?php
declare(strict_types=1);
namespace Modules\Unavailability\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class UnavailabilityController
{
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 index(Request $request): Response
{
$user = $request->user();
$userId = (int)($request->query('user_id', $user['id']));
if ($userId !== $user['id'] && !in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$records = $this->db->fetchAll(
"SELECT * FROM unavailability_records WHERE user_id = ? ORDER BY start_date DESC",
[$userId]
);
return Response::json(['records' => $records]);
}
public function create(Request $request): Response
{
$user = $request->user();
$targetUserId = (int)($request->input('user_id', $user['id']));
if ($targetUserId !== $user['id'] && !in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$id = $this->db->insert('unavailability_records', [
'user_id' => $targetUserId,
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date'),
'reason_category' => $request->input('reason_category'),
'notes' => $request->input('notes'),
]);
// Notify PL and Admin
$target = $this->db->fetchOne("SELECT full_name_en, assigned_pl_id FROM users WHERE id = ?", [$targetUserId]);
if ($target['assigned_pl_id']) {
$this->notif->createInformational($target['assigned_pl_id'], 'Unavailability Logged',
"{$target['full_name_en']} logged unavailability: {$request->input('start_date')} to {$request->input('end_date')}.");
}
return Response::json(['success' => true, 'id' => $id]);
}
public function update(Request $request, string $id): Response
{
$user = $request->user();
$record = $this->db->fetchOne("SELECT * FROM unavailability_records WHERE id = ?", [(int)$id]);
if (!$record) return Response::json(['error' => 'Not found'], 404);
if ($record['user_id'] !== $user['id'] && !in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$data = $request->only(['start_date', 'end_date', 'reason_category', 'notes']);
$data = array_filter($data, fn($v) => $v !== null);
$this->db->update('unavailability_records', $data, 'id = ?', [(int)$id]);
return Response::json(['success' => true]);
}
public function delete(Request $request, string $id): Response
{
$user = $request->user();
$record = $this->db->fetchOne("SELECT * FROM unavailability_records WHERE id = ?", [(int)$id]);
if (!$record) return Response::json(['error' => 'Not found'], 404);
if ($record['user_id'] !== $user['id'] && !in_array($user['role'], ['super_admin', 'admin'])) {
return Response::json(['error' => 'Forbidden'], 403);
}
$this->db->delete('unavailability_records', 'id = ?', [(int)$id]);
return Response::json(['success' => true]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$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
<?php
declare(strict_types=1);
namespace Modules\Users\Controllers;
use Engine\Core\Container;
use Engine\Core\Request;
use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Database\QueryBuilder;
use Engine\Auth\PermissionEngine;
use Engine\Auth\PasswordHasher;
use Engine\Validation\Validator;
use Engine\Audit\AuditLogger;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
use Engine\Template\TemplateEngine;
final class UserController
{
private Connection $db;
private PermissionEngine $perms;
private PasswordHasher $hasher;
private Validator $validator;
private AuditLogger $audit;
private NotificationManager $notif;
private CalculationEngine $calc;
private TemplateEngine $templates;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->hasher = $c->resolve(PasswordHasher::class);
$this->validator = $c->resolve(Validator::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->calc = $c->resolve(CalculationEngine::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function directory(Request $request): Response
{
$user = $request->user();
$page = max(1, (int)($request->query('page') ?? 1));
$search = trim($request->query('search', ''));
$role = $request->query('role');
$status = $request->query('status');
$type = $request->query('contractor_type');
$sql = "SELECT u.id, u.username, u.full_name_en, u.role, u.status, u.contractor_type, u.profile_photo_id, u.activation_date
FROM users u WHERE 1=1";
$params = [];
if ($search) {
$sql .= " AND (u.full_name_en LIKE ? OR u.username LIKE ?)";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
}
if ($role) {
$sql .= " AND u.role = ?";
$params[] = $role;
}
if ($status) {
$sql .= " AND u.status = ?";
$params[] = $status;
}
if ($type) {
$sql .= " AND u.contractor_type = ?";
$params[] = $type;
}
$countSql = str_replace('SELECT u.id, u.username, u.full_name_en, u.role, u.status, u.contractor_type, u.profile_photo_id, u.activation_date', 'SELECT COUNT(*)', $sql);
$total = (int)$this->db->fetchColumn($countSql, $params);
$perPage = 25;
$offset = ($page - 1) * $perPage;
$sql .= " ORDER BY u.full_name_en ASC LIMIT {$perPage} OFFSET {$offset}";
$users = $this->db->fetchAll($sql, $params);
// Attach board memberships
foreach ($users as &$u) {
$u['boards'] = $this->db->fetchAll(
"SELECT b.id, b.name FROM boards b JOIN board_members bm ON bm.board_id = b.id WHERE bm.user_id = ? AND b.is_archived = 0",
[$u['id']]
);
}
$data = [
'user' => $user,
'users' => $users,
'total' => $total,
'page' => $page,
'last_page' => (int)ceil($total / $perPage),
'search' => $search,
'filter_role' => $role,
'filter_status' => $status,
'filter_type' => $type,
];
if ($request->wantsJson()) {
return Response::json($data);
}
return Response::html($this->templates->render('users/directory', $data));
}
public function show(Request $request, string $userId): Response
{
$user = $request->user();
$targetId = (int)$userId;
$isOwnProfile = $user['id'] === $targetId;
$target = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$targetId]);
if (!$target) {
return Response::json(['error' => 'User not found'], 404);
}
// Strip sensitive data based on role
$profile = $this->buildProfileData($user, $target, $isOwnProfile);
if ($request->wantsJson()) {
return Response::json(['user' => $profile]);
}
return Response::html($this->templates->render('users/profile', [
'user' => $user,
'profile' => $profile,
'is_own' => $isOwnProfile,
]));
}
public function update(Request $request, string $userId): Response
{
$user = $request->user();
$targetId = (int)$userId;
$isOwnProfile = $user['id'] === $targetId;
$target = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$targetId]);
if (!$target) {
return Response::json(['error' => 'User not found'], 404);
}
// Permission check
if (!$isOwnProfile && !$this->perms->can($user, 'users.edit.any') && !$this->perms->can($user, 'users.edit.limited')) {
return Response::json(['error' => 'Forbidden'], 403);
}
$allowedFields = $this->getAllowedEditFields($user, $isOwnProfile);
$data = array_intersect_key($request->all(), array_flip($allowedFields));
if (empty($data)) {
return Response::json(['error' => 'No editable fields provided'], 422);
}
$before = $target;
$this->db->update('users', $data, 'id = ?', [$targetId]);
$this->audit->log($user, 'USER_UPDATED', 'user', $targetId, 'users', "/users/{$targetId}",
array_intersect_key($before, $data), $data, $request->ip(), $request->userAgent());
return Response::json(['success' => true, 'message' => 'Profile updated.']);
}
public function setSalary(Request $request, string $userId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'salary.set');
$targetId = (int)$userId;
$target = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$targetId]);
if (!$target) {
return Response::json(['error' => 'User not found'], 404);
}
$valid = $this->validator->validate($request->all(), [
'actual_salary' => 'required|numeric|min_value:0',
'reason' => 'required|min:10',
]);
if (!$valid) {
return Response::json(['errors' => $this->validator->errors()], 422);
}
$newSalary = (float)$request->input('actual_salary');
$oldSalary = $target['actual_salary'];
$this->db->transaction(function () use ($target, $targetId, $newSalary, $oldSalary, $request, $user) {
$this->db->update('users', [
'actual_salary' => $newSalary,
'actual_salary_set_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$targetId]);
$this->db->insert('salary_history', [
'user_id' => $targetId,
'old_actual_salary' => $oldSalary,
'new_actual_salary' => $newSalary,
'old_base_salary' => $target['base_salary'],
'new_base_salary' => $target['base_salary'],
'reason' => $request->input('reason'),
'changed_by_id' => $user['id'],
]);
$this->notif->createImportant($targetId, 'Salary Updated',
"Your salary has been updated to " . number_format($newSalary, 2) . " EGP.",
"/dashboard", 'user', $targetId);
});
$this->audit->log($user, 'SALARY_CHANGED', 'user', $targetId, 'users', "/users/{$targetId}/salary",
['actual_salary' => $oldSalary], ['actual_salary' => $newSalary], $request->ip(), $request->userAgent());
return Response::json(['success' => true, 'message' => 'Salary updated.']);
}
public function changeStatus(Request $request, string $userId): Response
{
$user = $request->user();
$targetId = (int)$userId;
$target = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$targetId]);
if (!$target) {
return Response::json(['error' => 'User not found'], 404);
}
$newStatus = $request->input('status');
$reason = $request->input('reason', '');
$validStatuses = ['onboarding', 'active', 'on_pip', 'suspended', 'terminated'];
if (!in_array($newStatus, $validStatuses)) {
return Response::json(['error' => 'Invalid status'], 422);
}
$oldStatus = $target['status'];
$this->db->transaction(function () use ($targetId, $newStatus, $oldStatus, $reason, $user) {
$updateData = ['status' => $newStatus];
if ($newStatus === 'active' && $oldStatus === 'onboarding') {
$updateData['activation_date'] = date('Y-m-d');
}
if ($newStatus === 'terminated') {
$updateData['is_active'] = 0;
}
$this->db->update('users', $updateData, 'id = ?', [$targetId]);
$this->db->insert('contractor_status_history', [
'user_id' => $targetId,
'from_status' => $oldStatus,
'to_status' => $newStatus,
'reason' => $reason ?: null,
'changed_by_id' => $user['id'],
]);
if ($newStatus === 'terminated') {
$this->db->delete('sessions', 'user_id = ?', [$targetId]);
}
});
$this->audit->log($user, 'STATUS_CHANGED', 'user', $targetId, 'users', "/users/{$targetId}/status",
['status' => $oldStatus], ['status' => $newStatus], $request->ip(), $request->userAgent());
if ($newStatus === 'active' && $oldStatus === 'onboarding') {
$this->notif->createImportant($targetId, 'Welcome to The Grind!',
'Your account is now active. Welcome aboard!', '/dashboard');
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $admin) {
$this->notif->createBlocking($admin['id'], 'New Contractor Activated',
"Contractor {$user['full_name_en']} has been activated. Set their Actual Salary.",
"/users/{$targetId}", 'user', $targetId);
}
}
return Response::json(['success' => true, 'message' => "Status changed to {$newStatus}."]);
}
public function privateNotes(Request $request, string $userId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'users.view_private_notes');
$notes = $this->db->fetchAll(
"SELECT n.*, u.full_name_en as author_name FROM contractor_private_notes n
JOIN users u ON u.id = n.author_id WHERE n.contractor_id = ? ORDER BY n.created_at DESC",
[(int)$userId]
);
return Response::json(['notes' => $notes]);
}
public function addPrivateNote(Request $request, string $userId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'users.create_private_notes');
$valid = $this->validator->validate($request->all(), ['content' => 'required|min:5']);
if (!$valid) {
return Response::json(['errors' => $this->validator->errors()], 422);
}
$id = $this->db->insert('contractor_private_notes', [
'contractor_id' => (int)$userId,
'author_id' => $user['id'],
'content' => $request->input('content'),
]);
return Response::json(['success' => true, 'id' => $id]);
}
public function sessions(Request $request, string $userId): Response
{
$user = $request->user();
$targetId = (int)$userId;
if ($user['id'] !== $targetId && $user['role'] !== 'super_admin') {
return Response::json(['error' => 'Forbidden'], 403);
}
$sessions = $this->db->fetchAll(
"SELECT id, ip_address, user_agent, last_activity_at, created_at FROM sessions WHERE user_id = ? ORDER BY last_activity_at DESC",
[$targetId]
);
return Response::json(['sessions' => $sessions]);
}
public function forceLogout(Request $request, string $userId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'users.force_logout');
$this->db->delete('sessions', 'user_id = ?', [(int)$userId]);
$this->audit->log($user, 'FORCE_LOGOUT', 'user', (int)$userId, 'users',
"/users/{$userId}/force-logout", null, null, $request->ip(), $request->userAgent());
return Response::json(['success' => true, 'message' => 'All sessions terminated.']);
}
public function salaryHistory(Request $request, string $userId): Response
{
$user = $request->user();
$targetId = (int)$userId;
if ($user['id'] !== $targetId && !$this->perms->can($user, 'users.view_salary.any')) {
return Response::json(['error' => 'Forbidden'], 403);
}
$history = $this->db->fetchAll(
"SELECT sh.*, u.full_name_en as changed_by_name FROM salary_history sh
JOIN users u ON u.id = sh.changed_by_id WHERE sh.user_id = ? ORDER BY sh.created_at DESC",
[$targetId]
);
return Response::json(['history' => $history]);
}
private function buildProfileData(array $viewer, array $target, bool $isOwn): array
{
$role = $viewer['role'];
$profile = [
'id' => $target['id'],
'username' => $target['username'],
'full_name_en' => $target['full_name_en'],
'role' => $target['role'],
'status' => $target['status'],
'contractor_type' => $target['contractor_type'],
'profile_photo_id' => $target['profile_photo_id'],
'activation_date' => $target['activation_date'],
];
if ($isOwn || in_array($role, ['super_admin', 'admin'])) {
$profile['full_name_ar'] = $target['full_name_ar'];
$profile['national_id'] = $target['national_id'];
$profile['date_of_birth'] = $target['date_of_birth'];
$profile['phone_primary'] = $target['phone_primary'];
$profile['phone_secondary'] = $target['phone_secondary'];
$profile['address'] = $target['address'];
$profile['emergency_contact_name'] = $target['emergency_contact_name'];
$profile['emergency_contact_phone'] = $target['emergency_contact_phone'];
$profile['emergency_contact_relationship'] = $target['emergency_contact_relationship'];
$profile['bank_name'] = $target['bank_name'];
$profile['bank_account_number'] = $target['bank_account_number'];
$profile['bank_account_holder'] = $target['bank_account_holder'];
$profile['contract_start_date'] = $target['contract_start_date'];
$profile['contract_end_date'] = $target['contract_end_date'];
$profile['theme_preference'] = $target['theme_preference'];
$profile['last_login_at'] = $target['last_login_at'];
$profile['created_at'] = $target['created_at'];
}
if ($isOwn || in_array($role, ['super_admin', 'admin'])) {
$profile['actual_salary'] = $target['actual_salary'];
$profile['base_salary'] = $target['base_salary'];
}
$profile['boards'] = $this->db->fetchAll(
"SELECT b.id, b.name, bm.role_on_board FROM boards b
JOIN board_members bm ON bm.board_id = b.id WHERE bm.user_id = ? AND b.is_archived = 0",
[$target['id']]
);
return $profile;
}
private function getAllowedEditFields(array $user, bool $isOwn): array
{
if ($user['role'] === 'super_admin') {
return ['full_name_en', 'full_name_ar', 'phone_primary', 'phone_secondary', 'address',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relationship',
'bank_name', 'bank_account_number', 'bank_account_holder', 'tax_registration_number',
'contractor_type', 'assigned_pl_id', 'contract_start_date', 'contract_end_date', 'theme_preference'];
}
if ($user['role'] === 'admin') {
return ['phone_primary', 'phone_secondary', 'address', 'emergency_contact_name',
'emergency_contact_phone', 'emergency_contact_relationship', 'bank_name',
'bank_account_number', 'bank_account_holder', 'assigned_pl_id', 'theme_preference'];
}
if ($isOwn) {
return ['phone_primary', 'phone_secondary', 'address', 'emergency_contact_name',
'emergency_contact_phone', 'emergency_contact_relationship', 'bank_name',
'bank_account_number', 'bank_account_holder', 'theme_preference'];
}
return [];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/users',
'middleware' => [
Middleware\AuthenticationMiddleware::class,
Middleware\CSRFMiddleware::class,
Middleware\BlockingNotificationMiddleware::class,
]
], function ($router) {
$router->get('/', Modules\Users\Controllers\UserController::class, 'directory');
$router->get('/{userId}', Modules\Users\Controllers\UserController::class, 'show');
$router->post('/{userId}', Modules\Users\Controllers\UserController::class, 'update');
$router->post('/{userId}/salary', Modules\Users\Controllers\UserController::class, 'setSalary');
$router->post('/{userId}/status', Modules\Users\Controllers\UserController::class, 'changeStatus');
$router->get('/{userId}/notes', Modules\Users\Controllers\UserController::class, 'privateNotes');
$router->post('/{userId}/notes', Modules\Users\Controllers\UserController::class, 'addPrivateNote');
$router->get('/{userId}/sessions', Modules\Users\Controllers\UserController::class, 'sessions');
$router->post('/{userId}/force-logout', Modules\Users\Controllers\UserController::class, 'forceLogout');
$router->get('/{userId}/salary-history', Modules\Users\Controllers\UserController::class, 'salaryHistory');
});
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Boards<?php $__engine->endSection(); ?>
<div class="container">
<div class="page-header">
<h1>📋 Boards</h1>
<?php if (in_array($user['role'], ['super_admin', 'admin'])): ?>
<button class="btn btn-primary" onclick="document.getElementById('create-board-modal').style.display='block'">+ New Board</button>
<?php endif; ?>
</div>
<div class="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>
</a>
<?php endforeach; ?>
</div>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?><?= $__engine->e($board['name']) ?><?php $__engine->endSection(); ?>
<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>
<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">
<?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>
<?php endif; ?>
</div>
<?php if (!empty($card['assignee_ids'])): ?>
<div class="card-assignees">
<span class="assignee-count">👤 <?= count($card['assignee_ids']) ?></span>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</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(); ?>
<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>
<div class="card-detail-body">
<div class="card-main">
<?php if ($card['description']): ?>
<div class="card-description">
<h3>Description</h3>
<div class="rich-text"><?= $card['description'] ?></div>
</div>
<?php endif; ?>
<?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>
<?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>
</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="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>
<?php endforeach; ?>
</div>
<?php if ($card['deadline']): ?>
<div class="sidebar-section">
<h4>⏰ Deadline</h4>
<p class="<?= strtotime($card['deadline']) < time() && !$card['done_at'] ? 'overdue' : '' ?>">
<?= date('M j, Y H:i', strtotime($card['deadline'])) ?>
</p>
</div>
<?php endif; ?>
<?php if ($card['bounty_amount']): ?>
<div class="sidebar-section">
<h4>💰 Bounty</h4>
<p class="bounty-amount"><?= number_format($card['bounty_amount'], 2) ?> EGP</p>
</div>
<?php endif; ?>
<?php if ($card['priority'] !== 'none'): ?>
<div class="sidebar-section">
<h4>Priority</h4>
<p><?= ucfirst($card['priority']) ?></p>
</div>
<?php endif; ?>
<div class="sidebar-section">
<h4>Info</h4>
<p>Created: <?= date('M j, Y', strtotime($card['created_at'])) ?></p>
<?php if ($card['done_at']): ?>
<p>Completed: <?= date('M j, Y', strtotime($card['done_at'])) ?></p>
<?php endif; ?>
</div>
</div>
</div>
</div>
\ No newline at end of file
<?php $__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>
</a>
<?php endforeach; ?>
</div>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Review Reports<?php $__engine->endSection(); ?>
<div class="container">
<div class="page-header">
<h1>📋 Report Review — <?= $__engine->e($date) ?></h1>
</div>
<?php if (empty($reports)): ?>
<div class="card"><p class="text-muted">No reports to review for this date.</p></div>
<?php endif; ?>
<?php foreach ($reports as $report): ?>
<div class="card" style="margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center">
<h3><?= $__engine->e($report['full_name_en']) ?></h3>
<span class="badge badge-<?= $report['status'] === 'submitted' ? 'warning' : ($report['status'] === 'approved' ? 'success' : 'danger') ?>">
<?= ucfirst(str_replace('_', ' ', $report['status'])) ?>
</span>
</div>
<?php foreach ($report['tasks'] as $task): ?>
<div class="task-entry-review" style="padding:8px;border:1px solid var(--border);border-radius:var(--radius);margin:8px 0">
<?php if ($task['card_key']): ?>
<strong><?= $__engine->e($task['card_key']) ?>: <?= $__engine->e($task['card_title']) ?></strong><br>
<?php endif; ?>
<p><?= nl2br($__engine->e($task['work_description'])) ?></p>
<small class="text-muted"><?= round($task['time_spent_minutes'] / 60, 1) ?>h · <?= ucfirst(str_replace('_', ' ', $task['task_status'])) ?></small>
</div>
<?php endforeach; ?>
<?php if ($report['blockers']): ?>
<p><strong>Blockers:</strong> <?= nl2br($__engine->e($report['blockers'])) ?></p>
<?php endif; ?>
<p class="text-muted">Total: <?= $report['total_hours'] ?>h · Submitted: <?= date('H:i', strtotime($report['submitted_at'])) ?>
· <?= $report['is_on_time'] ? '✅ On time' : '⏰ Late' ?></p>
<?php if (in_array($report['status'], ['submitted', 'late'])): ?>
<div style="display:flex;gap:8px;margin-top:12px">
<form method="POST" action="/reports/<?= $report['id'] ?>/review" style="display:inline">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<input type="hidden" name="action" value="approve">
<button type="submit" class="btn btn-success btn-sm">✅ Approve</button>
</form>
<form method="POST" action="/reports/<?= $report['id'] ?>/review" style="display:inline">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<input type="hidden" name="action" value="flag_vague">
<button type="submit" class="btn btn-warning btn-sm">⚠️ Flag Vague</button>
</form>
<form method="POST" action="/reports/<?= $report['id'] ?>/review" style="display:inline">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<input type="hidden" name="action" value="request_revision">
<button type="submit" class="btn btn-danger btn-sm">🔄 Request Revision</button>
</form>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Submit Daily Report<?php $__engine->endSection(); ?>
<div class="container">
<div class="card">
<h2>📋 Daily Report — <?= date('l, F j, Y') ?></h2>
<?php if ($report && in_array($report['status'], ['submitted','approved','approved_auto','late'])): ?>
<div class="alert alert-success">You've already submitted your report for today.</div>
<?php else: ?>
<form method="POST" action="/reports/submit" id="report-form">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<input type="hidden" name="report_date" value="<?= $__engine->e($report_date) ?>">
<div id="task-entries">
<h3>Tasks Worked On</h3>
<div class="task-entry" data-index="0">
<div class="form-group">
<label>Card (optional)</label>
<select name="tasks[0][card_id]">
<option value="">— No card linked —</option>
<?php foreach ($my_cards as $card): ?>
<option value="<?= $card['id'] ?>"><?= $__engine->e($card['card_key'] . ': ' . $card['title']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>Work Description (min 50 chars)</label>
<textarea name="tasks[0][work_description]" required minlength="50" rows="3"></textarea>
</div>
<div class="form-group" style="display:flex;gap:12px">
<div style="flex:1">
<label>Time Spent (minutes)</label>
<input type="number" name="tasks[0][time_spent_minutes]" required min="15" step="15" value="60">
</div>
<div style="flex:1">
<label>Status</label>
<select name="tasks[0][task_status]">
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="blocked">Blocked</option>
</select>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addTaskEntry()">+ Add Another Task</button>
<hr style="margin:20px 0">
<div class="form-group">
<label>Blockers (required if any task is blocked)</label>
<textarea name="blockers" rows="2"></textarea>
</div>
<div class="form-group">
<label>Additional Notes</label>
<textarea name="additional_notes" rows="2"></textarea>
</div>
<div class="form-group">
<label>How are you feeling?</label>
<div style="display:flex;gap:12px">
<label><input type="radio" name="mood" value="frustrated"> 😤 Frustrated</label>
<label><input type="radio" name="mood" value="neutral"> 😐 Neutral</label>
<label><input type="radio" name="mood" value="good" checked> 😊 Good</label>
<label><input type="radio" name="mood" value="on_fire"> 🔥 On Fire</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block">Submit Report</button>
</form>
<?php endif; ?>
</div>
</div>
<?php $__engine->section('scripts'); ?>
<script>
let taskIndex = 1;
function addTaskEntry() {
const container = document.getElementById('task-entries');
const first = container.querySelector('.task-entry');
const clone = first.cloneNode(true);
clone.dataset.index = taskIndex;
clone.querySelectorAll('[name]').forEach(el => {
el.name = el.name.replace(/\[\d+\]/, `[${taskIndex}]`);
if (el.tagName === 'TEXTAREA') el.value = '';
if (el.tagName === 'SELECT') el.selectedIndex = 0;
if (el.type === 'number') el.value = '60';
});
container.appendChild(clone);
taskIndex++;
}
</script>
<?php $__engine->endSection(); ?>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Team Directory<?php $__engine->endSection(); ?>
<div class="container">
<h1>👥 Team Directory</h1>
<form method="GET" action="/users" style="margin:16px 0;display:flex;gap:8px;flex-wrap:wrap">
<input type="text" name="search" value="<?= $__engine->e($search) ?>" placeholder="Search by name..." class="form-control" style="flex:1;min-width:200px">
<select name="role" class="form-control" style="width:auto">
<option value="">All Roles</option>
<option value="contractor" <?= $filter_role === 'contractor' ? 'selected' : '' ?>>Contractor</option>
<option value="project_leader" <?= $filter_role === 'project_leader' ? 'selected' : '' ?>>Project Leader</option>
<option value="admin" <?= $filter_role === 'admin' ? 'selected' : '' ?>>Admin</option>
</select>
<select name="status" class="form-control" style="width:auto">
<option value="">All Statuses</option>
<option value="active" <?= $filter_status === 'active' ? 'selected' : '' ?>>Active</option>
<option value="onboarding" <?= $filter_status === 'onboarding' ? 'selected' : '' ?>>Onboarding</option>
<option value="on_pip" <?= $filter_status === 'on_pip' ? 'selected' : '' ?>>On PIP</option>
</select>
<button type="submit" class="btn btn-primary">Search</button>
</form>
<div class="directory-grid">
<?php foreach ($users as $u): ?>
<a href="/users/<?= $u['id'] ?>" class="card directory-card">
<div class="directory-avatar">👤</div>
<h3><?= $__engine->e($u['full_name_en']) ?></h3>
<p class="text-muted">@<?= $__engine->e($u['username']) ?></p>
<span class="role-badge"><?= ucfirst(str_replace('_', ' ', $u['role'])) ?></span>
<span class="badge badge-<?= $u['status'] === 'active' ? 'success' : 'warning' ?>"><?= ucfirst($u['status']) ?></span>
<?php if (!empty($u['boards'])): ?>
<div class="directory-boards">
<?php foreach (array_slice($u['boards'], 0, 3) as $b): ?>
<span class="tag"><?= $__engine->e($b['name']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php if ($last_page > 1): ?>
<div style="text-align:center;margin:20px">
<?php for ($p = 1; $p <= $last_page; $p++): ?>
<a href="?page=<?= $p ?>&search=<?= urlencode($search) ?>" class="btn btn-sm <?= $p === $page ? 'btn-primary' : 'btn-secondary' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?><?= $__engine->e($profile['full_name_en']) ?> — Profile<?php $__engine->endSection(); ?>
<div class="container">
<div class="card">
<div style="display:flex;gap:20px;align-items:center;margin-bottom:20px">
<div class="profile-avatar" style="width:80px;height:80px;background:var(--primary);border-radius:50%;display:flex;align-items:center;justify-content:center;color:white;font-size:2em">
<?= strtoupper(substr($profile['full_name_en'], 0, 1)) ?>
</div>
<div>
<h1 style="margin:0"><?= $__engine->e($profile['full_name_en']) ?></h1>
<p class="text-muted">@<?= $__engine->e($profile['username']) ?> · <?= ucfirst(str_replace('_', ' ', $profile['role'])) ?></p>
<span class="badge badge-<?= $profile['status'] === 'active' ? 'success' : 'warning' ?>"><?= ucfirst($profile['status']) ?></span>
<?php if ($profile['contractor_type']): ?>
<span class="badge"><?= ucfirst(str_replace('_', ' ', $profile['contractor_type'])) ?></span>
<?php endif; ?>
</div>
</div>
<?php if (isset($profile['actual_salary'])): ?>
<div class="stat-row">
<span>Actual Salary</span>
<strong>EGP <?= number_format($profile['actual_salary'], 2) ?></strong>
</div>
<?php endif; ?>
<?php if (isset($profile['base_salary'])): ?>
<div class="stat-row">
<span>Base Salary</span>
<strong>EGP <?= number_format($profile['base_salary'], 2) ?></strong>
</div>
<?php endif; ?>
<?php if (!empty($profile['boards'])): ?>
<h3 style="margin-top:20px">Boards</h3>
<?php foreach ($profile['boards'] as $b): ?>
<a href="/boards/<?= $b['id'] ?>" class="tag"><?= $__engine->e($b['name']) ?> (<?= $b['role_on_board'] ?>)</a>
<?php endforeach; ?>
<?php endif; ?>
<?php if (isset($profile['phone_primary'])): ?>
<h3 style="margin-top:20px">Contact</h3>
<p>📱 <?= $__engine->e($profile['phone_primary']) ?></p>
<?php if (isset($profile['address'])): ?>
<p>📍 <?= $__engine->e($profile['address']) ?></p>
<?php endif; ?>
<?php endif; ?>
<?php if ($profile['activation_date']): ?>
<p class="text-muted">Active since: <?= date('M j, Y', strtotime($profile['activation_date'])) ?></p>
<?php endif; ?>
</div>
</div>
\ No newline at end of file
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