SettingsHelper = new SettingsHelper(); $this->UserHelper = new UserHelper(); } public function index(Request $request) { $user = Auth::user(); $companyId = $user->company_id ?? 1; if ($request->ajax()) { $users = User::where('company_id', $companyId) ->visibleInReports() ->select(['id', 'name', 'email', 'is_active', 'created_at']) ->get(); return datatables()->of($users) ->addColumn('status', function($user) { return $user->is_active == 1 ? 'Active' : 'Disabled'; }) ->addColumn('action', function($user) { return ' visibility View Report '; }) ->editColumn('created_at', function($user) { return $user->created_at ? $user->created_at->format('Y-m-d H:i:s') : ''; }) ->rawColumns(['status', 'action']) ->make(true); } // Get summary statistics $totalUsers = User::where('company_id', $companyId)->visibleInReports()->count(); $activeUsers = User::where('company_id', $companyId)->visibleInReports()->where('is_active', 1)->count(); $inactiveUsers = $totalUsers - $activeUsers; $widgets = [ [ 'name' => 'Total Users', 'bg_color' => 'blue', 'coll_class' => 'col-md-4', 'icon' => 'people', 'value' => $totalUsers ], [ 'name' => 'Active Users', 'bg_color' => 'green', 'coll_class' => 'col-md-4', 'icon' => 'check_circle', 'value' => $activeUsers ], [ 'name' => 'Disabled Users', 'bg_color' => 'red', 'coll_class' => 'col-md-4', 'icon' => 'block', 'value' => $inactiveUsers ] ]; $view = view('reporting::users_listing'); $view->with('widgets', $widgets); return $view; } private function getAgentDetailedStats($agentId, $conds) { $companyId = Auth::user()->company_id ?? 1; $queues = Auth::user()->accessibleQueueIds(); $userName = User::find($agentId)->name; // Get queue performance data $inbound = $this->getQueueData($queues, $conds, $userName); $performance_data = $this->getReport($inbound); // Get missed calls data $missed = $this->getMissedCalls($conds, $agentId) ?: [ 'inbound_total' => 0, 'inbound_completed' => 0, 'inbound_noanswer' => 0, 'inbound_rejected' => 0, 'outbound' => 0, 'outbound_duration' => 0 ]; // Combine both reports return [ 'overall_total_calls' => $performance_data['overall_answered_calls'] + $missed['inbound_rejected'] + $missed['inbound_noanswer'], 'overall_answered_calls' => $performance_data['overall_answered_calls'], 'overall_missed_calls' => $performance_data['overall_missed_calls'] + $missed['inbound_noanswer'] + $missed['inbound_rejected'], 'average_talk_time' => $performance_data['average_talk_time'], 'average_ring_time' => $performance_data['average_ring_time'], 'service_level_over' => $performance_data['service_level_over'], 'outbound_calls' => $missed['outbound'], 'outbound_duration' => $this->SettingsHelper->format_time($missed['outbound_duration']), 'inbound_rejected' => $missed['inbound_rejected'], 'inbound_no_answer' => $missed['inbound_noanswer'], 'overall_talk_time' => $performance_data['overall_talk_time'] ]; } private function getQueueData($queues, $conds, $userName) { // Create query to get latest events $latestEventsSub = DB::table('asteriskcdrdb.queuelog') ->select(DB::raw('MAX(id) as latest_id')) ->whereBetween('time', [$conds['from'], $conds['to']]) ->whereIn('event', ['COMPLETECALLER', 'COMPLETEAGENT', 'ABANDON', 'EXITWITHTIMEOUT']) ->whereIn('queuename', $queues) ->groupBy('callid'); $query = DB::table('asteriskcdrdb.queuelog as q') ->joinSub($latestEventsSub, 'latest', function ($join) { $join->on('q.id', '=', 'latest.latest_id'); }) ->select( 'q.id', 'q.callid', 'q.time', 'q.queuename', 'q.event', 'q.agent', 'q.data1', 'q.data2', 'q.data3', DB::raw("( SELECT SUM( CASE WHEN event IN ('EXITWITHTIMEOUT', 'ABANDON') THEN CAST(data3 AS UNSIGNED) WHEN event IN ('COMPLETECALLER', 'COMPLETEAGENT') THEN CAST(data1 AS UNSIGNED) ELSE 0 END ) FROM asteriskcdrdb.queuelog q2 WHERE q2.callid = q.callid ) AS total_ring_duration") ) ->where('q.agent', $userName) ->orderBy('q.queuename'); $excludedQueues = CompanySettings::get('report_excluded_groups'); if (!empty($excludedQueues) && is_array($excludedQueues)) { $query->whereNotIn('q.queuename', $excludedQueues); } return $query->get(); } public function profile(Request $request) { $conds = $this->SettingsHelper->defaultConds(); $user = Auth::user(); if ($request->has('daterange')) { $range = explode(' - ', $request->daterange); try { $conds['from'] = Carbon::createFromFormat('Y-m-d H:i:s', $range[0])->format('Y-m-d H:i:s'); $conds['to'] = Carbon::createFromFormat('Y-m-d H:i:s', $range[1])->format('Y-m-d H:i:s'); } catch (\Exception $e) { $conds['from'] = Carbon::now()->startOfDay()->format('Y-m-d H:i:s'); $conds['to'] = Carbon::now()->endOfDay()->format('Y-m-d H:i:s'); } } $user_ext = $request->has('user_ext') ? $request->user_ext : $user->id; // Handle AJAX requests for datatables if ($request->ajax()) { if ($request->get('table') == 'calls') { return $this->getUserCallsData($user_ext, $conds); } elseif ($request->get('table') == 'attendance') { return $this->getUserAttendanceData($user_ext, $conds); } elseif ($request->get('table') == 'tickets') { return $this->getUserTicketsData($user_ext, $conds); } elseif ($request->get('table') == 'timeline') { return $this->getUserTimelineData($user_ext, $conds); } } $report = $this->getAgentDetailedStats($user_ext, $conds); $ticketStats = $this->getTicketStats($user_ext, $conds); $attendanceStats = $this->getAttendanceStats($user_ext, $conds); $inbound_widgets = [ [ 'name' => 'Total Inbound', 'bg_color' => 'blue', 'coll_class' => 'col-md-3', 'icon' => 'phone_callback', 'stat_key' => 'overall_total_calls', 'value' => $report['overall_total_calls'] ], [ 'name' => 'Answered Calls', 'bg_color' => 'green', 'coll_class' => 'col-md-3', 'icon' => 'check_circle', 'stat_key' => 'overall_answered_calls', 'value' => $report['overall_answered_calls'] ], [ 'name' => 'Rejected Calls', 'bg_color' => 'deep-orange', 'coll_class' => 'col-md-3', 'icon' => 'block', 'stat_key' => 'inbound_rejected', 'value' => $report['inbound_rejected'] ], [ 'name' => 'No Answer', 'bg_color' => 'pink', 'coll_class' => 'col-md-3', 'icon' => 'phone_missed', 'stat_key' => 'inbound_no_answer', 'value' => $report['inbound_no_answer'] ], [ 'name' => 'Average Talk Time', 'bg_color' => 'purple', 'coll_class' => 'col-md-3', 'icon' => 'access_time', 'stat_key' => 'average_talk_time', 'value' => $report['average_talk_time'] ], [ 'name' => 'Average Ring Time', 'bg_color' => 'orange', 'coll_class' => 'col-md-3', 'icon' => 'timer', 'stat_key' => 'average_ring_time', 'value' => $report['average_ring_time'] ], [ 'name' => 'Total Talk Time', 'bg_color' => 'indigo', 'coll_class' => 'col-md-3', 'icon' => 'timer', 'stat_key' => 'overall_talk_time', 'value' => $report['overall_talk_time'] ] ]; $outbound_widgets = [ [ 'name' => 'Total Outbound', 'bg_color' => 'teal', 'coll_class' => 'col-md-3', 'icon' => 'call_made', 'stat_key' => 'outbound_calls', 'value' => $report['outbound_calls'] ], [ 'name' => 'Talk Duration', 'bg_color' => 'cyan', 'coll_class' => 'col-md-3', 'icon' => 'schedule', 'stat_key' => 'outbound_duration', 'value' => $report['outbound_duration'] ], [ 'name' => 'Service Level', 'bg_color' => 'light-blue', 'coll_class' => 'col-md-3', 'icon' => 'trending_up', 'stat_key' => 'service_level_over', 'value' => $report['service_level_over'] . '%' ] ]; $attendance_widgets = [ [ 'name' => 'Available', 'bg_color' => 'green', 'coll_class' => 'col-md-4', 'icon' => 'check_circle', 'value' => $this->SettingsHelper->format_time($attendanceStats['available_time']), 'subtitle' => 'Ready to take calls' ], [ 'name' => 'Break', 'bg_color' => 'orange', 'coll_class' => 'col-md-4', 'icon' => 'pause_circle', 'value' => $this->SettingsHelper->format_time($attendanceStats['pause_time']), 'subtitle' => 'Non-productive time' ], [ 'name' => 'Total', 'bg_color' => 'blue', 'coll_class' => 'col-md-4', 'icon' => 'access_time', 'value' => $this->SettingsHelper->format_time($attendanceStats['total_time']), 'subtitle' => 'Total tracked duration' ] ]; $ticket_widgets = [ [ 'name' => 'Total Tickets', 'bg_color' => 'blue', 'coll_class' => 'col-md-3', 'icon' => 'report_problem', 'value' => $ticketStats['total'] ], [ 'name' => 'Open Tickets', 'bg_color' => 'orange', 'coll_class' => 'col-md-3', 'icon' => 'access_time', 'value' => $ticketStats['open'] ], [ 'name' => 'Resolved Tickets', 'bg_color' => 'green', 'coll_class' => 'col-md-3', 'icon' => 'check_circle', 'value' => $ticketStats['resolved'] ], [ 'name' => 'Closed Tickets', 'bg_color' => 'grey', 'coll_class' => 'col-md-3', 'icon' => 'done_all', 'value' => $ticketStats['closed'] ] ]; // Set up view with filters $view = view('reporting::agent_profile'); $agent_names = $this->UserHelper->getCompanyUsers(); $agents_options = [ 'Agents' => $agent_names->pluck('name','id')->toArray() ]; $filter_area = view('reporting::widgets.filter_area'); $filter_area->with('conds', $conds); $filter_area->with('agents_options', $agents_options); $filter_area->with('user_ext', $request->user_ext); $filter_area->with('submit_route', route('reporting.agent.profile')); $view->with('filter_area', $filter_area); $view->with('inbound_widgets', $inbound_widgets); $view->with('outbound_widgets', $outbound_widgets); $view->with('attendance_widgets', $attendance_widgets); $view->with('ticket_widgets', $ticket_widgets); $view->with('report', $report); $view->with('conds', $conds); $view->with('user_ext', $request->user_ext); $view->with('attendanceStats', $attendanceStats); // $view->with('agents', $agents); return $view; } public function getReport($rows = array()){ if(!empty($rows)){ $results = array(); $overall_total_calls = 0; $overall_answered_calls = 0; $overall_missed_calls = 0; $overall_talk_time = 0; $overall_ring_time = 0; $return_array = array(); $service_level_ringe_time = CompanySettings::get('service_level_ringe_time'); $service_level_ringe_time = empty($service_level_ringe_time) ? 1000 : $service_level_ringe_time; $answered_over_10 = 0; foreach($rows as $index => $row){ $event = $row->event; $data1 = $row->total_ring_duration; $data2 = $row->data2; if($event == 'COMPLETECALLER' OR $event == 'COMPLETEAGENT'){ $overall_answered_calls++; if ($data1 <= $service_level_ringe_time) { $answered_over_10++; } }else{ $overall_missed_calls++; } $overall_total_calls++; $overall_ring_time += $data1; $overall_talk_time += $data2; } unset($results['NONE']); $return_array = array( 'overall_total_calls' => $overall_total_calls, 'overall_answered_calls' => $overall_answered_calls, 'overall_missed_calls' => $overall_missed_calls, 'overall_ring_time' => $overall_ring_time, 'overall_talk_time' => @$this->SettingsHelper->format_time($overall_talk_time), 'average_ring_time' => @$this->SettingsHelper->format_time( $overall_total_calls > 0 ? $overall_ring_time / $overall_total_calls : 0 ), 'average_talk_time' => @$this->SettingsHelper->format_time( $overall_answered_calls > 0 ? $overall_talk_time / $overall_answered_calls : 0 ), 'service_level_over' => ($overall_total_calls > 0) ? round(($answered_over_10 / $overall_total_calls) * 100, 2) : 0 ); return $return_array; } } private function getMissedCalls($conds = [], $agentId = null) { if (!empty($agentId)) { $from = $conds['from']; $to = $conds['to']; $result = \DB::table('asteriskcdrdb.cdr') ->select( DB::raw("SUM(CASE WHEN dst = {$agentId} THEN 1 ELSE 0 END) AS inbound_total"), DB::raw("SUM(CASE WHEN dst = {$agentId} AND disposition = 'ANSWERED' THEN 1 ELSE 0 END) AS inbound_completed"), DB::raw("COUNT(DISTINCT CASE WHEN dst = {$agentId} AND (disposition = 'NO ANSWER' OR disposition = 'FAILED') AND duration > 2 THEN CONCAT(calldate, src, dst) END) AS inbound_noanswer"), DB::raw("COUNT(DISTINCT CASE WHEN dst = {$agentId} AND disposition = 'BUSY' AND duration > 2 THEN CONCAT(calldate, src, dst) END) AS inbound_rejected"), DB::raw("SUM(CASE WHEN dst = {$agentId} THEN duration ELSE 0 END) AS inbound_duration"), DB::raw("SUM(CASE WHEN cnum = {$agentId} THEN 1 ELSE 0 END) AS outbound"), DB::raw("SUM(CASE WHEN cnum = {$agentId} THEN duration ELSE 0 END) AS outbound_duration") ) ->whereBetween('calldate', [$from, $to]) ->where(function ($query) use ($agentId) { $query->where('cnum', $agentId) ->orWhere('dst', $agentId); }) ->first(); if ($result) { return [ 'inbound_total' => $result->inbound_total, 'inbound_completed' => $result->inbound_completed, 'inbound_noanswer' => $result->inbound_noanswer, 'inbound_rejected' => $result->inbound_rejected, 'outbound' => $result->outbound, 'outbound_duration' => $result->outbound_duration, ]; } } return false; } public function getStats(Request $request) { $conds = $this->SettingsHelper->defaultConds(); $user = Auth::user(); if ($request->has('daterange')) { $range = explode(' - ', $request->daterange); try { $conds['from'] = Carbon::createFromFormat('Y-m-d H:i:s', $range[0])->format('Y-m-d H:i:s'); $conds['to'] = Carbon::createFromFormat('Y-m-d H:i:s', $range[1])->format('Y-m-d H:i:s'); } catch (\Exception $e) { $conds['from'] = Carbon::now()->startOfDay()->format('Y-m-d H:i:s'); $conds['to'] = Carbon::now()->endOfDay()->format('Y-m-d H:i:s'); } } $user_ext = $request->has('user_ext') ? $request->user_ext : $user->id; $report = $this->getAgentDetailedStats($user_ext, $conds); return response()->json($report); } private function getUserCallsData($userId, $conds) { // Get calls from call_logs table $data = CallLog::where('user_id', $userId) ->whereBetween('call_date', [$conds['from'], $conds['to']]) ->orderBy('call_date', 'desc') ->get(); // Pre-fetch recording files from CDR table for all linkedids $linkedids = $data->pluck('linkedid')->filter()->unique()->toArray(); $recordings = []; if (!empty($linkedids)) { $cdrRecords = DB::table('asteriskcdrdb.cdr') ->whereIn('linkedid', $linkedids) ->whereNotNull('recordingfile') ->where('recordingfile', '!=', '') ->pluck('recordingfile', 'linkedid') ->toArray(); $recordings = $cdrRecords; } return Datatables::of($data) ->addColumn('call_type', function($row) { return ucfirst($row->direction ?? 'Unknown'); }) ->addColumn('call_time_formatted', function($row) { return Carbon::parse($row->call_date)->format('Y-m-d h:i:s A'); }) ->addColumn('destination', function($row) { return $row->phone_number ?? 'N/A'; }) ->addColumn('ring_time_formatted', function($row) { return $row->ringtime ? $this->SettingsHelper->format_time($row->ringtime) : 'N/A'; }) ->addColumn('talk_time_formatted', function($row) { return $row->talktime ? $this->SettingsHelper->format_time($row->talktime) : '00:00:00'; }) ->addColumn('has_recording', function($row) use ($recordings) { return isset($recordings[$row->linkedid]) ? '1' : '0'; }) ->addColumn('action', function($row) use ($recordings) { if (!empty($row->linkedid) && isset($recordings[$row->linkedid])) { return ' headset Listen '; } return 'N/A'; }) ->rawColumns(['action']) ->make(true); } private function getUserAttendanceData($userId, $conds) { $data = DB::table('attendance') ->select( 'attendance.created_at', 'attendance.description', 'attendance.log_name' ) ->where('attendance.subject_id', $userId) ->whereBetween('attendance.created_at', [$conds['from'], $conds['to']]) ->orderBy('attendance.created_at', 'ASC') ->get() ->toArray(); // Calculate durations $endTime = Carbon::parse($conds['to']); $currentTime = Carbon::now(); $effectiveEndTime = $endTime->greaterThan($currentTime) ? $currentTime : $endTime; foreach ($data as $key => $item) { $nextItem = null; for ($i = $key + 1; $i < count($data); $i++) { $nextItem = $data[$i]; break; } if ($nextItem) { $diff = Carbon::parse($item->created_at)->diff(Carbon::parse($nextItem->created_at)); $totalSeconds = $diff->days * 86400 + $diff->h * 3600 + $diff->i * 60 + $diff->s; $item->duration = $this->SettingsHelper->format_time($totalSeconds); $item->duration_seconds = $totalSeconds; } else { // Last entry - calculate until effective end time $diff = Carbon::parse($item->created_at)->diff($effectiveEndTime); $totalSeconds = $diff->days * 86400 + $diff->h * 3600 + $diff->i * 60 + $diff->s; $item->duration = $this->SettingsHelper->format_time($totalSeconds); $item->duration_seconds = $totalSeconds; } } return Datatables::of($data) ->editColumn('created_at', function ($row) { return Carbon::parse($row->created_at)->format('Y-m-d H:i:s'); }) ->addColumn('status', function($row) { $statusColors = [ 'LOGGED_IN' => 'success', 'Reachable' => 'info', 'pause' => 'warning', 'unpause' => 'info', 'LOGGED_OUT' => 'danger' ]; $color = $statusColors[$row->description] ?? 'default'; return '' . ucfirst($row->description) . ''; }) ->rawColumns(['status']) ->make(true); } private function getUserTimelineData($userId, $conds) { $from = Carbon::parse($conds['from']); $to = Carbon::parse($conds['to']); $now = Carbon::now(); $effectiveTo = $to->greaterThan($now) ? $now : $to; // 1. Calls from call_logs table (Call Logs module) $calls = CallLog::where('user_id', $userId) ->whereBetween('call_date', [$from, $to]) ->orderBy('call_date', 'ASC') ->get(); // 3. Lead/Ticket actions by this user $activity = DB::table('activity_log') ->select('id', 'created_at', 'description', 'subject_type', 'subject_id', 'properties') ->where('causer_type', User::class) ->where('causer_id', $userId) ->whereIn('subject_type', ['Modules\Leads\Entities\Lead', 'Modules\Tickets\Entities\Ticket']) ->whereBetween('created_at', [$from, $to]) ->orderBy('created_at', 'ASC') ->limit(1000) ->get(); // Resolve related entity names in batch $leadIds = $calls->pluck('lead_id')->filter()->unique() ->merge($activity->where('subject_type', 'Modules\Leads\Entities\Lead')->pluck('subject_id')->filter()) ->unique(); $ticketIds = $activity->where('subject_type', 'Modules\Tickets\Entities\Ticket')->pluck('subject_id')->filter()->unique(); $contactIds = $calls->pluck('contact_id')->filter()->unique(); $leadNames = $leadIds->isNotEmpty() ? DB::table('leads')->whereIn('id', $leadIds)->pluck('full_name', 'id') : collect(); $ticketNames = $ticketIds->isNotEmpty() ? DB::table('tickets')->whereIn('id', $ticketIds)->pluck('name', 'id') : collect(); $contactNames = $contactIds->isNotEmpty() ? DB::table('contacts')->whereIn('id', $contactIds)->pluck('full_name', 'id') : collect(); // 4. Build merged event stream — only actual actions (calls, lead and // ticket create/update). Actions are kept regardless of the agent's // availability status; status is not part of the action timeline. $events = []; foreach ($calls as $row) { $duration = $row->duration ? $this->SettingsHelper->format_time($row->duration) : ''; $related = $row->lead_id && isset($leadNames[$row->lead_id]) ? $leadNames[$row->lead_id] : ($row->contact_id && isset($contactNames[$row->contact_id]) ? $contactNames[$row->contact_id] : ($row->phone_number ?? '')); $directionLabel = ucfirst($row->direction ?? 'Call'); $events[] = [ 'time' => $row->call_date, 'category' => 'CALL', 'label' => $directionLabel, 'description' => $row->outcome ?? '', 'related' => $related, 'duration' => $duration, 'seconds' => (int) $row->duration ]; } foreach ($activity as $row) { $subject = str_contains($row->subject_type, 'Lead') ? 'LEAD' : 'TICKET'; $entityId = $row->subject_id; $name = ''; if ($row->properties) { $props = json_decode($row->properties, true); $attrs = $props['attributes'] ?? []; if ($subject == 'LEAD') { $name = $attrs['full_name'] ?? trim(($attrs['first_name'] ?? '') . ' ' . ($attrs['last_name'] ?? '')); } else { $name = $attrs['name'] ?? ''; } } if (!$name && $entityId) { $name = $subject == 'LEAD' ? ($leadNames[$entityId] ?? '') : ($ticketNames[$entityId] ?? ''); } $events[] = [ 'time' => $row->created_at, 'category' => $subject, 'label' => ucfirst($row->description), 'description' => trim($name) ?: ($subject == 'LEAD' ? 'Lead' : 'Ticket'), 'related' => $entityId ? '#' . $entityId : '', 'duration' => '' ]; } usort($events, function ($a, $b) { return strcmp($a['time'], $b['time']); }); // 5. Action-driven timeline blocks. // // Every recorded action (inbound/outbound call, lead create/update, // ticket create/update, ...) stays in the timeline regardless of the // agent's availability status (Available, Unavailable, Paused, ...). // The timeline simply runs from the first action to the last action, // chronologically. Actions close together form an ACTIVE period, and // any large gap between them appears as an IDLE period of inactivity. // No action is filtered out because of the user's status. $clusterGap = 600; // events <= 10 min apart -> same ACTIVE period $idleMin = 900; // gaps >= 15 min -> explicit IDLE block $maxBlockEvents = 50; $blocks = []; if (!empty($events)) { $pts = []; foreach ($events as $e) { $t = Carbon::parse($e['time']); $end = $t->copy()->addSeconds((int) ($e['seconds'] ?? 0)); $pts[] = ['time' => $t, 'end' => $end, 'event' => $e]; } $flushActive = function ($end) use (&$blocks, &$activeStart, &$activeEvents, $maxBlockEvents) { if ($activeStart === null) { return; } $blockEvents = array_slice($activeEvents, -$maxBlockEvents); $blocks[] = [ 'type' => 'ACTIVE', 'start' => $activeStart, 'end' => $end, 'events' => $blockEvents, 'truncated' => count($activeEvents) > $maxBlockEvents ]; $activeStart = null; $activeEvents = []; }; $first = $pts[0]; $activeStart = $first['time']; $activeEvents = [$first['event']]; $prevEnd = $first['end']; for ($i = 1; $i < count($pts); $i++) { $t = $pts[$i]['time']; $gap = $prevEnd->diffInSeconds($t); if ($gap > $clusterGap) { $flushActive($prevEnd); if ($gap >= $idleMin) { $blocks[] = ['type' => 'IDLE', 'start' => $prevEnd, 'end' => $t]; } $activeStart = $t; $activeEvents = [$pts[$i]['event']]; } else { $activeEvents[] = $pts[$i]['event']; } // Keep the running end monotonic so an action that happened // inside a long call doesn't shrink the inactivity window. $candidateEnd = $pts[$i]['end']; if ($candidateEnd->greaterThan($prevEnd)) { $prevEnd = $candidateEnd; } } $flushActive($prevEnd); } // 6. Split blocks at day boundaries and compute per-day summaries $boundaries = []; $b = $from->copy()->startOfDay()->addDay(); while ($b->lessThan($effectiveTo)) { $boundaries[] = $b->copy(); $b->addDay(); } $splitBlock = function ($blk) use (&$splitBlock, $boundaries) { $s = Carbon::parse($blk['start']); $e = Carbon::parse($blk['end']); $cut = null; foreach ($boundaries as $bd) { if ($s->lt($bd) && $e->gt($bd)) { $cut = $bd; break; } } if (!$cut) { return [$blk]; } $left = $blk; $left['end'] = $cut->copy(); $right = $blk; $right['start'] = $cut->copy(); return array_merge($splitBlock($left), $splitBlock($right)); }; $totals = ['active_seconds' => 0, 'idle_seconds' => 0, 'no_activity_seconds' => 0, 'break_seconds' => 0, 'unavailable_seconds' => 0, 'calls' => 0, 'leads' => 0, 'tickets' => 0, 'actions' => 0]; $dayMap = []; $cursor = $from->copy()->startOfDay(); $lastDay = $effectiveTo->copy()->startOfDay(); while ($cursor->lte($lastDay)) { $dayMap[$cursor->format('Y-m-d')] = []; $cursor->addDay(); } foreach ($blocks as $blk) { foreach ($splitBlock($blk) as $piece) { $date = Carbon::parse($piece['start'])->format('Y-m-d'); if (isset($dayMap[$date])) { $dayMap[$date][] = $piece; } } } $days = []; $rulerFrom = null; $rulerTo = null; foreach ($dayMap as $date => $dayBlocks) { if (empty($dayBlocks)) { continue; } usort($dayBlocks, function ($a, $b) { return strcmp($a['start']->format('Y-m-d H:i:s'), $b['start']->format('Y-m-d H:i:s')); }); $sum = [ 'shift_start' => null, 'shift_end' => null, 'shift_seconds' => 0, 'active_seconds' => 0, 'idle_seconds' => 0, 'no_activity_seconds' => 0, 'break_seconds' => 0, 'unavailable_seconds' => 0, 'calls' => 0, 'leads' => 0, 'tickets' => 0, 'actions' => 0 ]; $outBlocks = []; foreach ($dayBlocks as $blk) { $s = Carbon::parse($blk['start']); $e = Carbon::parse($blk['end']); $secs = $s->diffInSeconds($e); if ($sum['shift_start'] === null || $s->lt($sum['shift_start'])) { $sum['shift_start'] = $s; } if ($sum['shift_end'] === null || $e->gt($sum['shift_end'])) { $sum['shift_end'] = $e; } if ($blk['type'] == 'ACTIVE') { $sum['active_seconds'] += $secs; } else { $sum['idle_seconds'] += $secs; } $blockOut = [ 'type' => $blk['type'], 'start' => $s->format('Y-m-d H:i:s'), 'end' => $e->format('Y-m-d H:i:s'), 'minutes' => round($secs / 60) ]; if (array_key_exists('state', $blk)) { $blockOut['state'] = $blk['state']; } if (isset($blk['reason']) && $blk['reason']) { $blockOut['reason'] = $blk['reason']; } if ($blk['type'] == 'ACTIVE') { $counts = ['calls' => 0, 'leads' => 0, 'tickets' => 0, 'actions' => 0]; $eventsOut = []; foreach ($blk['events'] as $ev) { $counts['actions']++; if ($ev['category'] == 'CALL') { $counts['calls']++; } elseif ($ev['category'] == 'LEAD') { $counts['leads']++; } else { $counts['tickets']++; } $eventsOut[] = [ 'time' => Carbon::parse($ev['time'])->format('H:i:s'), 'type' => $ev['category'], 'label' => $ev['label'], 'description' => $ev['description'], 'related' => $ev['related'], 'duration' => $ev['duration'] ]; } $blockOut['counts'] = $counts; $blockOut['events'] = $eventsOut; if (!empty($blk['truncated'])) { $blockOut['truncated'] = true; } $sum['calls'] += $counts['calls']; $sum['leads'] += $counts['leads']; $sum['tickets'] += $counts['tickets']; $sum['actions'] += $counts['actions']; } $outBlocks[] = $blockOut; if ($rulerFrom === null || $s->lt($rulerFrom)) { $rulerFrom = $s; } if ($rulerTo === null || $e->gt($rulerTo)) { $rulerTo = $e; } } if ($sum['shift_start'] && $sum['shift_end']) { $sum['shift_seconds'] = $sum['shift_start']->diffInSeconds($sum['shift_end']); } foreach (['active_seconds', 'idle_seconds', 'no_activity_seconds', 'break_seconds', 'unavailable_seconds', 'calls', 'leads', 'tickets', 'actions'] as $k) { $totals[$k] += $sum[$k]; } $days[] = [ 'date' => $date, 'label' => Carbon::parse($date)->format('l, F j, Y'), 'summary' => [ 'shift_start' => $sum['shift_start'] ? $sum['shift_start']->format('H:i') : null, 'shift_end' => $sum['shift_end'] ? $sum['shift_end']->format('H:i') : null, 'shift_seconds' => $sum['shift_seconds'], 'active_seconds' => $sum['active_seconds'], 'idle_seconds' => $sum['idle_seconds'], 'no_activity_seconds' => $sum['no_activity_seconds'], 'break_seconds' => $sum['break_seconds'], 'unavailable_seconds' => $sum['unavailable_seconds'], 'calls' => $sum['calls'], 'leads' => $sum['leads'], 'tickets' => $sum['tickets'], 'actions' => $sum['actions'] ], 'blocks' => $outBlocks ]; } if ($rulerFrom === null) { $rulerFrom = $from; $rulerTo = $effectiveTo; } return response()->json([ 'range' => [ 'from' => $from->format('Y-m-d H:i:s'), 'to' => $effectiveTo->format('Y-m-d H:i:s') ], 'ruler' => [ 'from' => $rulerFrom->format('Y-m-d H:i:s'), 'to' => $rulerTo->format('Y-m-d H:i:s') ], 'days' => $days, 'totals' => $totals ]); } private function getAttendanceStats($userId, $conds) { $attendanceController = new AttendanceController(app('Modules\Calendar\Service\CalendarService')); $summaryData = $attendanceController->calculateAttendanceSummary($conds, $userId, null); if (empty($summaryData)) { return [ 'available_time' => 0, 'pause_time' => 0, 'total_time' => 0, 'utilization' => 0, 'pause_reasons' => [], 'status_breakdown' => [], ]; } $userSummary = reset($summaryData); $availableSeconds = $userSummary['Reachable'] ?? 0; $pauseSeconds = $userSummary['pause'] ?? 0; $totalSeconds = $userSummary['total'] ?? 0; $loggedInSeconds = $userSummary['LOGGED_IN'] ?? 0; $pauseReasons = []; if (!empty($userSummary['pause_reasons'])) { foreach ($userSummary['pause_reasons'] as $reason => $data) { $pauseReasons[$reason] = $data['duration'] ?? 0; } } // Display totals exclude LOGGED_IN time $displayTotalSeconds = max(0, $totalSeconds - $loggedInSeconds); $staffedTime = $availableSeconds + $pauseSeconds; // Exclude LOGGED_IN from staffed time for display $utilization = $staffedTime > 0 ? round(($availableSeconds / $staffedTime) * 100, 2) : 0; $statusBreakdown = [ 'Available' => [ 'seconds' => $availableSeconds, 'formatted' => $this->secondsToTime($availableSeconds), 'type' => 'available' ], 'Break' => [ 'seconds' => $pauseSeconds, 'formatted' => $this->secondsToTime($pauseSeconds), 'type' => 'break' ], ]; if (!empty($userSummary['pause_reasons'])) { foreach ($userSummary['pause_reasons'] as $reason => $data) { $statusBreakdown[$reason] = [ 'seconds' => $data['duration'] ?? 0, 'formatted' => $data['formatted_duration'] ?? '00:00:00', 'type' => 'break_reason' ]; } } // Exclude LOGGED_IN from display breakdown $statusBreakdown['Total'] = [ 'seconds' => $displayTotalSeconds, 'formatted' => $this->secondsToTime($displayTotalSeconds), 'type' => 'total' ]; return [ 'available_time' => $availableSeconds, 'pause_time' => $pauseSeconds, 'total_time' => $displayTotalSeconds, 'utilization' => $utilization, 'pause_reasons' => $pauseReasons, 'status_breakdown' => $statusBreakdown, ]; } private function secondsToTime($seconds) { $hours = floor($seconds / 3600); $minutes = floor(($seconds % 3600) / 60); $seconds = $seconds % 60; return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds); } private function getTicketStats($userId, $conds) { $companyId = Auth::user()->company_id ?? 1; $tickets = Ticket::where('company_id', $companyId) ->where('ticket_owner_id', $userId) ->whereBetween('created_at', [$conds['from'], $conds['to']]); $total = $tickets->count(); // Get counts by status $open = Ticket::where('company_id', $companyId) ->where('ticket_owner_id', $userId) ->whereBetween('created_at', [$conds['from'], $conds['to']]) ->whereHas('ticketStatus', function($q) { $q->where('system_name', 'open'); })->count(); $resolved = Ticket::where('company_id', $companyId) ->where('ticket_owner_id', $userId) ->whereBetween('created_at', [$conds['from'], $conds['to']]) ->whereHas('ticketStatus', function($q) { $q->where('system_name', 'resolved'); })->count(); $closed = Ticket::where('company_id', $companyId) ->where('ticket_owner_id', $userId) ->whereBetween('created_at', [$conds['from'], $conds['to']]) ->whereHas('ticketStatus', function($q) { $q->where('system_name', 'closed'); })->count(); return [ 'total' => $total, 'open' => $open, 'resolved' => $resolved, 'closed' => $closed ]; } private function getUserTicketsData($userId, $conds) { $companyId = Auth::user()->company_id ?? 1; $data = Ticket::with(['ticketStatus', 'ticketPriority', 'contact', 'account']) ->where('company_id', $companyId) ->where('ticket_owner_id', $userId) ->whereBetween('created_at', [$conds['from'], $conds['to']]); return Datatables::of($data) ->addColumn('ticket_number', function($row) { return '#' . str_pad($row->id, 5, '0', STR_PAD_LEFT); }) ->addColumn('status', function($row) { $statusColors = [ 'open' => 'warning', 'in_progress' => 'info', 'resolved' => 'success', 'closed' => 'default' ]; $systemName = $row->ticketStatus->system_name ?? 'open'; $color = $statusColors[$systemName] ?? 'default'; return '' . ($row->ticketStatus->name ?? 'N/A') . ''; }) ->addColumn('priority', function($row) { $priorityColors = [ 'low' => 'success', 'medium' => 'warning', 'high' => 'danger', 'critical' => 'danger' ]; $priorityName = strtolower($row->ticketPriority->name ?? 'medium'); $color = $priorityColors[$priorityName] ?? 'default'; return '' . ($row->ticketPriority->name ?? 'N/A') . ''; }) ->addColumn('customer', function($row) { if ($row->contact) { return $row->contact->first_name . ' ' . $row->contact->last_name; } elseif ($row->account) { return $row->account->name; } return 'N/A'; }) ->editColumn('created_at', function($row) { return $row->created_at ? $row->created_at->format('Y-m-d H:i:s') : ''; }) ->editColumn('due_date', function($row) { return $row->due_date ? $row->due_date->format('Y-m-d') : 'N/A'; }) ->addColumn('action', function($row) { if (\Auth::user()->hasPermissionTo('tickets.browse')) { return ' visibility View '; } return 'N/A'; }) ->rawColumns(['status', 'priority', 'action']) ->make(true); } }