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);
}
}