src/ApplicationBundle/Controller/TaskDashboardController.php line 360

Open in your IDE?
  1. <?php
  2. namespace ApplicationBundle\Controller;
  3. use ApplicationBundle\Interfaces\SessionCheckInterface;
  4. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  5. use Doctrine\DBAL\Connection;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use ApplicationBundle\Service\NotificationService;
  8. class TaskDashboardController extends GenericController implements SessionCheckInterface
  9. {
  10.     private function getCompanyId(Request $request): int
  11.     {
  12.         return (int) $request->getSession()->get(UserConstants::USER_COMPANY_ID0);
  13.     }
  14.     private function getEmployeeId(Request $request): int
  15.     {
  16.         return (int) $request->getSession()->get(UserConstants::USER_EMPLOYEE_ID0);
  17.     }
  18.     private function isAdminOverride(Request $request): bool
  19.     {
  20.         $session $request->getSession();
  21.         return
  22.             (int) $session->get(UserConstants::USER_TYPE) === UserConstants::USER_TYPE_SYSTEM ||
  23.             (int) $session->get(UserConstants::IS_BUDDYBEE_ADMIN0) === 1;
  24.     }
  25.     private function isManagerScope(Request $request): bool
  26.     {
  27.         $session $request->getSession();
  28.         return
  29.             $this->isAdminOverride($request) ||
  30.             (int) $session->get(UserConstants::USER_TYPE) === UserConstants::USER_TYPE_MANAGEMENT_USER;
  31.     }
  32.     private function notificationService(): NotificationService
  33.     {
  34.         return $this->get('app.notification_service');
  35.     }
  36.     private function buildTaskAlerts(Request $requeststring $dashboardType): array
  37.     {
  38.         $companyId $this->getCompanyId($request);
  39.         $employeeId $this->getEmployeeId($request);
  40.         $service $this->notificationService();
  41.         $alerts = [];
  42.         if ($dashboardType === 'reviewer') {
  43.             $outWarning $service->getUserTaskOutWarnings($employeeId$companyId);
  44.             $reviewerPending $service->getReviewerPendingTasks($employeeId$companyId);
  45.             $overdue $service->getOverdueReviews($companyId'/tasks/review?filter=overdue');
  46.             foreach ([$overdue$reviewerPending$outWarning] as $alert) {
  47.                 if (!empty($alert['hasAlert'])) {
  48.                     $alerts[] = $alert;
  49.                 }
  50.             }
  51.         } elseif ($dashboardType === 'my') {
  52.             $outWarning $service->getUserTaskOutWarnings($employeeId$companyId);
  53.             $rejected $service->getRejectedTasks($employeeId$companyId);
  54.             foreach ([$rejected$outWarning] as $alert) {
  55.                 if (!empty($alert['hasAlert'])) {
  56.                     $alerts[] = $alert;
  57.                 }
  58.             }
  59.         } elseif ($dashboardType === 'manager') {
  60.             $overdue $service->getOverdueReviews($companyId'/tasks/manager?filter=overdue');
  61.             if (!empty($overdue['hasAlert'])) {
  62.                 $alerts[] = $overdue;
  63.             }
  64.         }
  65.         usort($alerts, static function (array $left, array $right): int {
  66.             $priorityCompare = ((int) ($left['priority'] ?? 99)) <=> ((int) ($right['priority'] ?? 99));
  67.             if ($priorityCompare !== 0) {
  68.                 return $priorityCompare;
  69.             }
  70.             return ((int) ($right['count'] ?? 0)) <=> ((int) ($left['count'] ?? 0));
  71.         });
  72.         return array_slice($alerts03);
  73.     }
  74.     private function getDashboardFilter(Request $requeststring $dashboardType): string
  75.     {
  76.         $filter trim((string) $request->query->get('filter'''));
  77.         $allowed = [
  78.             'reviewer' => ['overdue''pending'],
  79.             'my' => ['active''rejected'],
  80.             'manager' => ['overdue''non_compliant'],
  81.         ];
  82.         if (!isset($allowed[$dashboardType])) {
  83.             return '';
  84.         }
  85.         if ($filter === '' || !in_array($filter$allowed[$dashboardType], true)) {
  86.             return '';
  87.         }
  88.         return $filter;
  89.     }
  90.     private function applyDashboardFilter(array $rowsstring $dashboardTypestring $filter): array
  91.     {
  92.         if ($filter === '') {
  93.             return $rows;
  94.         }
  95.         return array_values(array_filter($rows, static function (array $row) use ($dashboardType$filter): bool {
  96.             $state = (string) ($row['currentState'] ?? '');
  97.             $submittedAgeHours = (int) ($row['submittedAgeHours'] ?? 0);
  98.             if ($dashboardType === 'reviewer') {
  99.                 if ($filter === 'overdue') {
  100.                     return $submittedAgeHours >= 48;
  101.                 }
  102.                 return $state === 'submitted';
  103.             }
  104.             if ($dashboardType === 'my') {
  105.                 if ($filter === 'rejected') {
  106.                     return $state === 'rejected';
  107.                 }
  108.                 return in_array($state, ['created''in_progress'], true);
  109.             }
  110.             if ($dashboardType === 'manager') {
  111.                 if ($filter === 'non_compliant') {
  112.                     return false;
  113.                 }
  114.                 return $submittedAgeHours >= 48;
  115.             }
  116.             return true;
  117.         }));
  118.     }
  119.     private function summarizeAlerts(array $alerts): array
  120.     {
  121.         return $alerts;
  122.     }
  123.     private function formatHoursSince($submittedAt): string
  124.     {
  125.         if (empty($submittedAt)) {
  126.             return 'N/A';
  127.         }
  128.         try {
  129.             $submitted = new \DateTime((string) $submittedAt);
  130.         } catch (\Exception $e) {
  131.             return 'N/A';
  132.         }
  133.         $diff = (new \DateTime())->diff($submitted);
  134.         $hours = ((int) $diff->days 24) + (int) $diff->h;
  135.         if ($hours >= 48) {
  136.             return round($hours 241) . 'd';
  137.         }
  138.         return $hours 'h';
  139.     }
  140.     private function fetchLatestLogMap(Connection $conn, array $planningItemIds, ?string $logType null): array
  141.     {
  142.         $planningItemIds array_values(array_unique(array_filter(array_map('intval'$planningItemIds))));
  143.         if (empty($planningItemIds)) {
  144.             return [];
  145.         }
  146.         $sql 'SELECT t.* FROM task_log t WHERE t.planning_item_id IN (' implode(','$planningItemIds) . ')';
  147.         if ($logType !== null) {
  148.             $sql .= ' AND t.log_type = ' $conn->quote($logType);
  149.         }
  150.         $sql .= ' ORDER BY t.id DESC';
  151.         $rows $conn->fetchAllAssociative($sql);
  152.         $map = [];
  153.         foreach ($rows as $row) {
  154.             $planningItemId = (int) ($row['planning_item_id'] ?? 0);
  155.             if ($planningItemId === || isset($map[$planningItemId])) {
  156.                 continue;
  157.             }
  158.             $map[$planningItemId] = $row;
  159.         }
  160.         return $map;
  161.     }
  162.     private function baseTaskQuery(Connection $connint $companyId)
  163.     {
  164.         $qb $conn->createQueryBuilder();
  165.         $qb->from('planning_item''p')
  166.             ->leftJoin('p''employee''e''e.employee_id = p.assigned_to AND p.assigned_to_type = 1')
  167.             ->leftJoin('p''sys_user''u''u.user_id = p.assigned_to AND p.assigned_to_type = 2')
  168.             ->leftJoin('p''employee''r''r.employee_id = p.reviewer_id')
  169.             ->leftJoin('p''project''pr''pr.project_id = p.project_id')
  170.             ->select(
  171.                 'p.id AS planningItemId',
  172.                 'p.item_alias AS taskTitle',
  173.                 'p.project_id AS projectId',
  174.                 'p.task_context_type AS taskContextType',
  175.                 'p.current_state AS currentState',
  176.                 'p.review_status AS reviewStatus',
  177.                 'p.reviewer_id AS reviewerId',
  178.                 'p.assigned_to AS assignedTo',
  179.                 'p.assigned_to_type AS assignedToType',
  180.                 'p.completion_percentage AS completionPercentage',
  181.                 'p.submitted_at AS submittedAt',
  182.                 'p.approved_at AS approvedAt',
  183.                 'p.rejected_at AS rejectedAt',
  184.                 'p.expected_output AS expectedOutput',
  185.                 'p.kpi_counted AS kpiCounted',
  186.                 'COALESCE(NULLIF(pr.project_name, \'\'), \'General\') AS projectName',
  187.                 'COALESCE(NULLIF(e.name, \'\'), NULLIF(u.name, \'\'), CONCAT(COALESCE(e.first_name, \'\'), \' \', COALESCE(e.last_name, \'\')), CONCAT(\'User #\', p.assigned_to)) AS assignedName',
  188.                 'COALESCE(NULLIF(r.name, \'\'), CONCAT(COALESCE(r.first_name, \'\'), \' \', COALESCE(r.last_name, \'\')), CONCAT(\'Employee #\', p.reviewer_id)) AS reviewerName',
  189.                 'TIMESTAMPDIFF(HOUR, p.submitted_at, NOW()) AS submittedAgeHours'
  190.             )
  191.             ->where('p.company_id = :companyId')
  192.             ->setParameter('companyId'$companyId);
  193.         return $qb;
  194.     }
  195.     private function decorateRows(array $rows, array $latestLogs = []): array
  196.     {
  197.         foreach ($rows as &$row) {
  198.             $planningItemId = (int) ($row['planningItemId'] ?? 0);
  199.             $log $latestLogs[$planningItemId] ?? [];
  200.             $row['submissionEvidenceExists'] = !empty(trim((string) ($log['evidence_files'] ?? '')));
  201.             $row['submissionWorkCompleted'] = (string) ($log['work_completed'] ?? '');
  202.             $row['submissionEvidenceFiles'] = (string) ($log['evidence_files'] ?? '');
  203.             $row['submissionEvidenceNote'] = (string) ($log['evidence_note'] ?? '');
  204.             $row['submissionBlockerDetail'] = (string) ($log['blocker_detail'] ?? '');
  205.             $row['submissionNextAction'] = (string) ($log['next_action'] ?? '');
  206.             $row['submissionReviewDecision'] = (string) ($log['review_decision'] ?? '');
  207.             $row['submissionReviewComment'] = (string) ($log['review_comment'] ?? '');
  208.             $row['latestActivityType'] = (string) ($log['log_type'] ?? '');
  209.             $row['latestActivityAt'] = (string) ($log['created_at'] ?? '');
  210.             $row['latestActivityLabel'] = ucfirst(str_replace('_'' ', (string) ($log['log_type'] ?? 'session')));
  211.             $row['submittedAgeLabel'] = $this->formatHoursSince($row['submittedAt'] ?? null);
  212.             $row['stateLabel'] = $this->buildStateLabel($row);
  213.             $row['stateClass'] = $this->buildStateClass($row);
  214.         }
  215.         unset($row);
  216.         return $rows;
  217.     }
  218.     private function buildStateLabel(array $row): string
  219.     {
  220.         $state = (string) ($row['currentState'] ?? '');
  221.         $review = (string) ($row['reviewStatus'] ?? '');
  222.         $progress = (int) ($row['completionPercentage'] ?? 0);
  223.         if ($state === 'approved' || $review === 'approved' || (int) ($row['kpiCounted'] ?? 0) === 1) {
  224.             return 'KPI Counted';
  225.         }
  226.         if ($state === 'submitted' || $review === 'pending') {
  227.             return 'Work Finished (Pending Approval)';
  228.         }
  229.         if ($state === 'rejected' || $review === 'rejected') {
  230.             return 'Rework Needed';
  231.         }
  232.         if ($progress >= 100) {
  233.             return 'Work Finished (Pending Approval)';
  234.         }
  235.         if ($state === 'in_progress') {
  236.             return 'In Progress';
  237.         }
  238.         return 'Created';
  239.     }
  240.     private function buildStateClass(array $row): string
  241.     {
  242.         $state = (string) ($row['currentState'] ?? '');
  243.         $review = (string) ($row['reviewStatus'] ?? '');
  244.         $progress = (int) ($row['completionPercentage'] ?? 0);
  245.         if ($state === 'approved' || $review === 'approved' || (int) ($row['kpiCounted'] ?? 0) === 1) {
  246.             return 'is-approved';
  247.         }
  248.         if ($state === 'submitted' || $review === 'pending' || $progress >= 100) {
  249.             return 'is-submitted';
  250.         }
  251.         if ($state === 'rejected' || $review === 'rejected') {
  252.             return 'is-rejected';
  253.         }
  254.         return 'is-progress';
  255.     }
  256.     public function reviewerDashboardAction(Request $request)
  257.     {
  258.         $em $this->getDoctrine()->getManager();
  259.         $conn $em->getConnection();
  260.         $companyId $this->getCompanyId($request);
  261.         $currentEmployeeId $this->getEmployeeId($request);
  262.         $isOverride $this->isAdminOverride($request);
  263.         $qb $this->baseTaskQuery($conn$companyId)
  264.             ->andWhere('p.current_state = :submittedState')
  265.             ->andWhere('p.review_status = :pendingReview')
  266.             ->setParameter('submittedState''submitted')
  267.             ->setParameter('pendingReview''pending')
  268.             ->orderBy('p.submitted_at''ASC')
  269.             ->addOrderBy('p.id''DESC');
  270.         if (!$isOverride) {
  271.             $qb->andWhere('p.reviewer_id = :reviewerId')
  272.                 ->setParameter('reviewerId'$currentEmployeeId);
  273.         }
  274.         $tasks $qb->executeQuery()->fetchAllAssociative();
  275.         $latestLogs $this->fetchLatestLogMap($connarray_column($tasks'planningItemId'), 'submission');
  276.         $tasks $this->decorateRows($tasks$latestLogs);
  277.         $filter $this->getDashboardFilter($request'reviewer');
  278.         $tasks $this->applyDashboardFilter($tasks'reviewer'$filter);
  279.         foreach ($tasks as &$task) {
  280.             $task['isOverdueReview'] = (int) ($task['submittedAgeHours'] ?? 0) >= 48;
  281.         }
  282.         unset($task);
  283.         return $this->render('@Application/pages/tasks/dashboard_reviewer.html.twig', [
  284.             'page_title' => 'Reviewer Dashboard',
  285.             'currentEmployeeId' => $currentEmployeeId,
  286.             'isAdminOverride' => $isOverride,
  287.             'activeFilter' => $filter,
  288.             'tasks' => $tasks,
  289.             'pendingCount' => count($tasks),
  290.             'overdueCount' => count(array_filter($tasks, static function ($row) {
  291.                 return !empty($row['isOverdueReview']);
  292.             })),
  293.             'taskAlerts' => $this->buildTaskAlerts($request'reviewer'),
  294.         ]);
  295.     }
  296.     public function myTasksDashboardAction(Request $request)
  297.     {
  298.         $em $this->getDoctrine()->getManager();
  299.         $conn $em->getConnection();
  300.         $companyId $this->getCompanyId($request);
  301.         $currentEmployeeId $this->getEmployeeId($request);
  302.         $qb $this->baseTaskQuery($conn$companyId)
  303.             ->andWhere('p.assigned_to = :assignedTo')
  304.             ->andWhere('p.assigned_to_type = 1')
  305.             ->setParameter('assignedTo'$currentEmployeeId)
  306.             ->orderBy('p.updated_at''DESC')
  307.             ->addOrderBy('p.id''DESC');
  308.         $tasks $qb->executeQuery()->fetchAllAssociative();
  309.         $latestLogs $this->fetchLatestLogMap($connarray_column($tasks'planningItemId'));
  310.         $tasks $this->decorateRows($tasks$latestLogs);
  311.         $filter $this->getDashboardFilter($request'my');
  312.         $tasks $this->applyDashboardFilter($tasks'my'$filter);
  313.         $sections = [
  314.             'in_progress' => [],
  315.             'submitted' => [],
  316.             'rejected' => [],
  317.             'approved' => [],
  318.         ];
  319.         foreach ($tasks as $task) {
  320.             $state = (string) ($task['currentState'] ?? 'created');
  321.             if (!isset($sections[$state])) {
  322.                 $sections['in_progress'][] = $task;
  323.                 continue;
  324.             }
  325.             $sections[$state][] = $task;
  326.         }
  327.         return $this->render('@Application/pages/tasks/dashboard_my_tasks.html.twig', [
  328.             'page_title' => 'My Tasks',
  329.             'currentEmployeeId' => $currentEmployeeId,
  330.             'activeFilter' => $filter,
  331.             'sections' => $sections,
  332.             'totalTasks' => count($tasks),
  333.             'taskAlerts' => $this->buildTaskAlerts($request'my'),
  334.         ]);
  335.     }
  336.     public function managerDashboardAction(Request $request)
  337.     {
  338.         $em $this->getDoctrine()->getManager();
  339.         $conn $em->getConnection();
  340.         $companyId $this->getCompanyId($request);
  341.         $currentEmployeeId $this->getEmployeeId($request);
  342.         $isOverride $this->isAdminOverride($request);
  343.         $pendingQb $this->baseTaskQuery($conn$companyId)
  344.             ->andWhere('p.current_state = :submittedState')
  345.             ->andWhere('p.review_status = :pendingReview')
  346.             ->setParameter('submittedState''submitted')
  347.             ->setParameter('pendingReview''pending')
  348.             ->orderBy('p.submitted_at''ASC')
  349.             ->addOrderBy('p.id''DESC');
  350.         $pendingTasks $pendingQb->executeQuery()->fetchAllAssociative();
  351.         $pendingLogs $this->fetchLatestLogMap($connarray_column($pendingTasks'planningItemId'), 'submission');
  352.         $pendingTasks $this->decorateRows($pendingTasks$pendingLogs);
  353.         $managerFilter $this->getDashboardFilter($request'manager');
  354.         $pendingTasks $this->applyDashboardFilter($pendingTasks'manager'$managerFilter);
  355.         foreach ($pendingTasks as &$task) {
  356.             $task['isOverdueReview'] = (int) ($task['submittedAgeHours'] ?? 0) >= 48;
  357.         }
  358.         unset($task);
  359.         $todayStart = (new \DateTime('today'))->format('Y-m-d 00:00:00');
  360.         $todayEnd = (new \DateTime('today'))->format('Y-m-d 23:59:59');
  361.         $todayStartEpoch = (new \DateTime('today'))->getTimestamp();
  362.         $nowEpoch time();
  363.         $fortyEightHoursAgo = (new \DateTime('-48 hours'))->format('Y-m-d H:i:s');
  364.         $submittedToday = (int) $conn->fetchOne(
  365.             'SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND submitted_at BETWEEN :startTs AND :endTs',
  366.             [
  367.                 'companyId' => $companyId,
  368.                 'startTs' => $todayStart,
  369.                 'endTs' => $todayEnd,
  370.             ]
  371.         );
  372.         $approvedToday = (int) $conn->fetchOne(
  373.             'SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND approved_at BETWEEN :startTs AND :endTs',
  374.             [
  375.                 'companyId' => $companyId,
  376.                 'startTs' => $todayStart,
  377.                 'endTs' => $todayEnd,
  378.             ]
  379.         );
  380.         $rejectedToday = (int) $conn->fetchOne(
  381.             'SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND rejected_at BETWEEN :startTs AND :endTs',
  382.             [
  383.                 'companyId' => $companyId,
  384.                 'startTs' => $todayStart,
  385.                 'endTs' => $todayEnd,
  386.             ]
  387.         );
  388.         $pendingReviews = (int) $conn->fetchOne(
  389.             "SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND current_state = 'submitted' AND review_status = 'pending'",
  390.             ['companyId' => $companyId]
  391.         );
  392.         $overdueReviews = (int) $conn->fetchOne(
  393.             "SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND current_state = 'submitted' AND review_status = 'pending' AND submitted_at <= :deadline",
  394.             [
  395.                 'companyId' => $companyId,
  396.                 'deadline' => $fortyEightHoursAgo,
  397.             ]
  398.         );
  399.         $todayInProgress = (int) $conn->fetchOne(
  400.             "SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND current_state IN ('created', 'in_progress')",
  401.             ['companyId' => $companyId]
  402.         );
  403.         $systemSubmitted = (int) $conn->fetchOne(
  404.             "SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND current_state = 'submitted'",
  405.             ['companyId' => $companyId]
  406.         );
  407.         $systemRejected = (int) $conn->fetchOne(
  408.             "SELECT COUNT(*) FROM planning_item WHERE company_id = :companyId AND current_state = 'rejected'",
  409.             ['companyId' => $companyId]
  410.         );
  411.         $reviewMetricsSql "
  412.             SELECT
  413.                 AVG(CASE
  414.                     WHEN p.review_status = 'approved' AND p.approved_at IS NOT NULL AND p.submitted_at IS NOT NULL
  415.                     THEN TIMESTAMPDIFF(HOUR, p.submitted_at, p.approved_at)
  416.                     ELSE NULL
  417.                 END) AS avgReviewTimeHours,
  418.                 SUM(CASE WHEN p.current_state = 'submitted' AND p.review_status = 'pending' AND p.submitted_at <= :deadline THEN 1 ELSE 0 END) AS overdueReviewCount,
  419.                 SUM(CASE WHEN p.review_status = 'approved' THEN 1 ELSE 0 END) AS approvedReviewCount,
  420.                 SUM(CASE WHEN p.review_status = 'rejected' THEN 1 ELSE 0 END) AS rejectedReviewCount
  421.             FROM planning_item p
  422.             WHERE p.company_id = :companyId
  423.         ";
  424.         $reviewMetrics $conn->fetchAssociative($reviewMetricsSql, [
  425.             'companyId' => $companyId,
  426.             'deadline' => $fortyEightHoursAgo,
  427.         ]) ?: [];
  428.         $avgReviewTimeHours = isset($reviewMetrics['avgReviewTimeHours']) ? round((float) $reviewMetrics['avgReviewTimeHours'], 1) : 0;
  429.         $reviewDecisionTotal = (int) ($reviewMetrics['approvedReviewCount'] ?? 0) + (int) ($reviewMetrics['rejectedReviewCount'] ?? 0);
  430.         $rejectionRate $reviewDecisionTotal 0
  431.             round(((int) ($reviewMetrics['rejectedReviewCount'] ?? 0) / $reviewDecisionTotal) * 1001)
  432.             : 0;
  433.         $reviewSpeedBadge 'On Track';
  434.         $reviewSpeedClass 'is-approved';
  435.         if ($avgReviewTimeHours 48) {
  436.             $reviewSpeedBadge 'Critical Delay';
  437.             $reviewSpeedClass 'is-rejected';
  438.         } elseif ($avgReviewTimeHours 24) {
  439.             $reviewSpeedBadge 'Slow Review System';
  440.             $reviewSpeedClass 'is-submitted';
  441.         }
  442.         $systemHealth = [
  443.             'inProgress' => $todayInProgress,
  444.             'submitted' => $systemSubmitted,
  445.             'overdue' => (int) ($reviewMetrics['overdueReviewCount'] ?? 0),
  446.             'rejected' => $systemRejected,
  447.         ];
  448.         $openSessionsSql "
  449.             SELECT
  450.                 t.id AS taskLogId,
  451.                 t.user_id AS userId,
  452.                 t.planning_item_id AS planningItemId,
  453.                 t.actual_start_ts AS actualStartTs,
  454.                 p.item_alias AS taskTitle,
  455.                 COALESCE(NULLIF(e.name, ''), u.name, CONCAT('User #', t.user_id)) AS userName
  456.             FROM task_log t
  457.             LEFT JOIN planning_item p ON p.id = t.planning_item_id
  458.             LEFT JOIN sys_user u ON u.user_id = t.user_id
  459.             LEFT JOIN employee e ON e.user_id = t.user_id
  460.             WHERE p.company_id = :companyId
  461.               AND t.working_status = 1
  462.               AND t.actual_start_ts BETWEEN :todayStartEpoch AND :nowEpoch
  463.             ORDER BY t.actual_start_ts DESC, t.id DESC
  464.         ";
  465.         $openSessions $conn->fetchAllAssociative($openSessionsSql, [
  466.             'companyId' => $companyId,
  467.             'todayStartEpoch' => $todayStartEpoch,
  468.             'nowEpoch' => $nowEpoch,
  469.         ]);
  470.         foreach ($openSessions as &$sessionRow) {
  471.             $startTs = (int) ($sessionRow['actualStartTs'] ?? 0);
  472.             $sessionRow['openHours'] = $startTs round(((time() - $startTs) / 3600), 1) : 0;
  473.         }
  474.         unset($sessionRow);
  475.         $nonCompliantUsers = [];
  476.         foreach ($openSessions as $sessionRow) {
  477.             $userId = (int) ($sessionRow['userId'] ?? 0);
  478.             if ($userId === 0) {
  479.                 continue;
  480.             }
  481.             if (!isset($nonCompliantUsers[$userId])) {
  482.                 $nonCompliantUsers[$userId] = [
  483.                     'userId' => $userId,
  484.                     'userName' => $sessionRow['userName'] ?? ('User #' $userId),
  485.                     'activeSessions' => 0,
  486.                     'oldestStartTs' => (int) ($sessionRow['actualStartTs'] ?? 0),
  487.                     'oldestLabel' => $sessionRow['actualStartTs'] ? date('Y-m-d H:i', (int) $sessionRow['actualStartTs']) : 'N/A',
  488.                     'openHours' => 0,
  489.                 ];
  490.             }
  491.             $nonCompliantUsers[$userId]['activeSessions']++;
  492.             $startTs = (int) ($sessionRow['actualStartTs'] ?? 0);
  493.             if ($startTs && ($nonCompliantUsers[$userId]['oldestStartTs'] === || $startTs $nonCompliantUsers[$userId]['oldestStartTs'])) {
  494.                 $nonCompliantUsers[$userId]['oldestStartTs'] = $startTs;
  495.                 $nonCompliantUsers[$userId]['oldestLabel'] = date('Y-m-d H:i'$startTs);
  496.             }
  497.         }
  498.         foreach ($nonCompliantUsers as &$userRow) {
  499.             $userRow['openHours'] = $userRow['oldestStartTs'] > round(((time() - $userRow['oldestStartTs']) / 3600), 1) : 0;
  500.         }
  501.         unset($userRow);
  502.         $nonCompliantUsers array_values($nonCompliantUsers);
  503.         return $this->render('@Application/pages/tasks/dashboard_manager.html.twig', [
  504.             'page_title' => 'Manager Dashboard',
  505.             'currentEmployeeId' => $currentEmployeeId,
  506.             'isAdminOverride' => $isOverride,
  507.             'activeFilter' => $managerFilter,
  508.             'pendingTasks' => $pendingTasks,
  509.             'submittedToday' => $submittedToday,
  510.             'approvedToday' => $approvedToday,
  511.             'rejectedToday' => $rejectedToday,
  512.             'pendingReviews' => $pendingReviews,
  513.             'overdueReviews' => $overdueReviews,
  514.             'openSessions' => $openSessions,
  515.             'nonCompliantUsers' => $nonCompliantUsers,
  516.             'managerFilter' => $managerFilter,
  517.             'avgReviewTimeHours' => $avgReviewTimeHours,
  518.             'rejectionRate' => $rejectionRate,
  519.             'reviewSpeedBadge' => $reviewSpeedBadge,
  520.             'reviewSpeedClass' => $reviewSpeedClass,
  521.             'funnelCounts' => [
  522.                 'inProgress' => $todayInProgress,
  523.                 'submitted' => $systemSubmitted,
  524.                 'approved' => (int) ($reviewMetrics['approvedReviewCount'] ?? 0),
  525.                 'rejected' => $systemRejected,
  526.             ],
  527.             'systemHealth' => $systemHealth,
  528.             'taskAlerts' => $this->buildTaskAlerts($request'manager'),
  529.         ]);
  530.     }
  531.     public function kpiDashboardAction(Request $request)
  532.     {
  533.         $em $this->getDoctrine()->getManager();
  534.         $conn $em->getConnection();
  535.         $companyId $this->getCompanyId($request);
  536.         $sql "
  537.             SELECT
  538.                 p.assigned_to AS assignedTo,
  539.                 p.assigned_to_type AS assignedToType,
  540.                 COALESCE(NULLIF(e.name, ''), NULLIF(u.name, ''), CONCAT(COALESCE(e.first_name, ''), ' ', COALESCE(e.last_name, '')), CONCAT('User #', p.assigned_to)) AS userName,
  541.                 COUNT(*) AS totalTasks,
  542.                 SUM(CASE WHEN p.current_state = 'submitted' THEN 1 ELSE 0 END) AS submittedTasks,
  543.                 SUM(CASE WHEN p.review_status = 'approved' THEN 1 ELSE 0 END) AS approvedTasks,
  544.                 SUM(CASE WHEN p.review_status = 'rejected' THEN 1 ELSE 0 END) AS rejectedTasks,
  545.                 SUM(CASE WHEN p.review_status = 'approved' AND COALESCE(p.kpi_counted, 0) = 1 THEN 1 ELSE 0 END) AS kpiCountedTasks
  546.             FROM planning_item p
  547.             LEFT JOIN employee e ON e.employee_id = p.assigned_to AND p.assigned_to_type = 1
  548.             LEFT JOIN sys_user u ON u.user_id = p.assigned_to AND p.assigned_to_type = 2
  549.             WHERE p.company_id = :companyId
  550.             GROUP BY
  551.                 p.assigned_to,
  552.                 p.assigned_to_type,
  553.                 e.employee_id,
  554.                 e.name,
  555.                 e.first_name,
  556.                 e.last_name,
  557.                 u.user_id,
  558.                 u.name
  559.             ORDER BY approvedTasks DESC, submittedTasks DESC, totalTasks DESC
  560.         ";
  561.         $rows $conn->fetchAllAssociative($sql, ['companyId' => $companyId]);
  562.         $totals = [
  563.             'submitted' => 0,
  564.             'approved' => 0,
  565.             'rejected' => 0,
  566.             'kpiCounted' => 0,
  567.             'total' => 0,
  568.         ];
  569.         foreach ($rows as &$row) {
  570.             $row['submittedTasks'] = (int) ($row['submittedTasks'] ?? 0);
  571.             $row['approvedTasks'] = (int) ($row['approvedTasks'] ?? 0);
  572.             $row['rejectedTasks'] = (int) ($row['rejectedTasks'] ?? 0);
  573.             $row['kpiCountedTasks'] = (int) ($row['kpiCountedTasks'] ?? 0);
  574.             $row['totalTasks'] = (int) ($row['totalTasks'] ?? 0);
  575.             $decisions max($row['approvedTasks'] + $row['rejectedTasks'], 0);
  576.             $row['approvalRate'] = $decisions round(($row['approvedTasks'] / $decisions) * 1001) : 0;
  577.             $totals['submitted'] += $row['submittedTasks'];
  578.             $totals['approved'] += $row['approvedTasks'];
  579.             $totals['rejected'] += $row['rejectedTasks'];
  580.             $totals['kpiCounted'] += $row['kpiCountedTasks'];
  581.             $totals['total'] += $row['totalTasks'];
  582.         }
  583.         unset($row);
  584.         $totals['approvalRate'] = ($totals['approved'] + $totals['rejected']) > 0
  585.             round(($totals['approved'] / ($totals['approved'] + $totals['rejected'])) * 1001)
  586.             : 0;
  587.         return $this->render('@Application/pages/tasks/dashboard_kpi.html.twig', [
  588.             'page_title' => 'Basic KPI Dashboard',
  589.             'rows' => $rows,
  590.             'totals' => $totals,
  591.             'taskAlerts' => [],
  592.         ]);
  593.     }
  594. }