$period['start'], 'end' => $period['end'], 'days' => 60]);
}
$by_bucket = $grouped(reportBucketSql('tickets.ticket_closed_at', $buckets['unit']), $filter);
// By agent
$names = [];
$sql = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar FROM users WHERE user_type = 1");
while ($row = mysqli_fetch_assoc($sql)) {
$names[intval($row['user_id'])] = $row;
}
$agent_rows = [];
foreach ($grouped('tickets.ticket_assigned_to', $filter) as $user_id => $figures) {
$user_id = intval($user_id);
$agent_rows[] = ['user_id' => $user_id, 'agent' => $names[$user_id]['user_name'] ?? '', 'avatar' => $names[$user_id]['user_avatar'] ?? ''] + $figures;
}
usort($agent_rows, function ($a, $b) { return $b['rated'] <=> $a['rated'] ?: $b['closed'] <=> $a['closed']; });
// By client - the most bad ratings first, as those are the calls to make
$client_rows = [];
$sql = mysqli_query($mysqli, "SELECT client_id, client_name, client_abbreviation, " . SATISFACTION_COLUMNS . "
FROM tickets JOIN clients ON tickets.ticket_client_id = client_id WHERE $filter GROUP BY client_id, client_name, client_abbreviation");
while ($row = mysqli_fetch_assoc($sql)) {
$client_rows[] = ['client_id' => intval($row['client_id']), 'client' => $row['client_name'], 'abbr' => $row['client_abbreviation']] + satisfactionFigures($row);
}
usort($client_rows, function ($a, $b) {
return $b['bad'] <=> $a['bad'] ?: ($a['score'] ?? 101) <=> ($b['score'] ?? 101) ?: $b['rated'] <=> $a['rated'];
});
// What the rating follows - the first reply against its SLA, and how long the fix took
$drivers = [];
$reply = $grouped('tickets.ticket_response_sla_met', $filter . " AND tickets.ticket_sla_id > 0");
foreach (['1' => 'First reply in time', '0' => 'First reply late'] as $key => $label) {
if (isset($reply[$key])) {
$drivers[] = ['label' => $label, 'group' => 'First reply against the SLA'] + $reply[$key];
}
}
$fix = $grouped("CASE WHEN tickets.ticket_resolved_at IS NULL THEN 'none'
WHEN TIMESTAMPDIFF(HOUR, tickets.ticket_created_at, tickets.ticket_resolved_at) < 24 THEN 'day'
WHEN TIMESTAMPDIFF(HOUR, tickets.ticket_created_at, tickets.ticket_resolved_at) < 72 THEN 'three'
WHEN TIMESTAMPDIFF(HOUR, tickets.ticket_created_at, tickets.ticket_resolved_at) < 168 THEN 'week' ELSE 'longer' END", $filter);
foreach (['day' => 'Resolved within a day', 'three' => 'Resolved in 1 to 3 days', 'week' => 'Resolved in 3 to 7 days', 'longer' => 'Resolved after a week'] as $key => $label) {
if (isset($fix[$key])) {
$drivers[] = ['label' => $label, 'group' => 'Time to resolve'] + $fix[$key];
}
}
$driver_rows = [];
foreach ($drivers as $driver) {
if (!$driver_rows || end($driver_rows)['group'] !== $driver['group']) {
$driver_rows[] = ['_heading' => $driver['group'], 'group' => $driver['group']];
}
$driver_rows[] = $driver;
}
// Every bad rating, newest first
$bad_rows = [];
$sql = mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority, ticket_client_id, ticket_assigned_to,
ticket_created_at, ticket_first_response_at, ticket_resolved_at, ticket_closed_at, ticket_sla_id, ticket_response_sla_met, ticket_resolution_sla_met,
client_name, client_abbreviation, contact_name, user_name, user_avatar,
TIMESTAMPDIFF(SECOND, ticket_created_at, ticket_first_response_at) AS reply_seconds,
TIMESTAMPDIFF(SECOND, ticket_created_at, COALESCE(ticket_resolved_at, ticket_closed_at)) AS resolve_seconds,
(SELECT SUM(TIME_TO_SEC(ticket_reply_time_worked)) FROM ticket_replies WHERE ticket_reply_ticket_id = tickets.ticket_id AND ticket_reply_archived_at IS NULL) AS worked_seconds
FROM tickets
LEFT JOIN clients ON ticket_client_id = client_id
LEFT JOIN contacts ON ticket_contact_id = contact_id
LEFT JOIN users ON ticket_assigned_to = user_id
WHERE $filter AND tickets.ticket_feedback = 'Bad'
ORDER BY ticket_closed_at DESC");
while ($row = mysqli_fetch_assoc($sql)) {
$bad_rows[] = $row;
}
$figure_columns = [
'closed' => ['label' => 'Closed', 'type' => 'count'],
'rated' => ['label' => 'Rated', 'type' => 'count'],
'rated_percent' => ['label' => 'Rated share', 'type' => 'percent', 'class' => 'text-body-secondary', 'title' => 'Closed tickets the contact rated'],
'good' => ['label' => 'Good', 'type' => 'count'],
'bad' => ['label' => 'Bad', 'type' => 'count', 'html' => function ($row) {
return $row['bad'] ? '' . $row['bad'] . '' : '–';
}],
'score' => ['label' => 'Satisfaction', 'type' => 'percent', 'title' => 'Good ratings, of the good and bad', 'html' => function ($row) {
return reportRateHtml($row['score']);
}],
];
$agent_columns = ['agent' => ['label' => 'Assigned to', 'csv' => function ($row) { return $row['user_id'] ? $row['agent'] : 'Unassigned'; }, 'html' => function ($row) {
return formatAssignee($row['user_id'], $row['agent'], $row['avatar'], true);
}]] + $figure_columns;
$client_columns = ['client' => ['label' => 'Client', 'html' => function ($row) {
return reportClientCell($row['client_id'], $row['client'], $row['abbr'], 'tickets.php');
}]] + $figure_columns;
$driver_columns = [
'label' => ['label' => 'How the ticket went', 'sort' => false],
'rated' => ['label' => 'Rated', 'type' => 'count', 'sort' => false],
'good' => ['label' => 'Good', 'type' => 'count', 'sort' => false],
'bad' => $figure_columns['bad'] + ['sort' => false],
'score' => $figure_columns['score'] + ['sort' => false],
'meter' => ['label' => '', 'sort' => false, 'csv' => false, 'class' => 'w-25', 'html' => function ($row) {
return $row['score'] === null ? '' : '
';
}],
];
$bad_columns = [
'ticket' => ['label' => 'Ticket', 'class' => 'itflow-report-text', 'csv' => function ($row) { return ticketReference($row) . ' ' . $row['ticket_subject']; }, 'html' => function ($row) {
return reportPriorityDot($row['ticket_priority']) . reportTicketLink($row);
}],
'client_name' => ['label' => 'Client', 'html' => function ($row) {
return reportClientCell($row['ticket_client_id'], $row['client_name'] ?? '', $row['client_abbreviation'] ?? '');
}],
'contact_name' => ['label' => 'Rated by', 'html' => function ($row) {
return $row['contact_name'] ? '' . escapeHtml($row['contact_name']) . '' : '-';
}],
'user_name' => ['label' => 'Assigned to', 'csv' => function ($row) { return $row['user_name'] ?: 'Unassigned'; }, 'html' => function ($row) {
return formatAssignee($row['ticket_assigned_to'], $row['user_name'] ?? '', $row['user_avatar'] ?? '', true);
}],
'reply_seconds' => ['label' => 'First reply', 'type' => 'duration', 'html' => function ($row) {
if ($row['reply_seconds'] === null) {
return '-';
}
$late = $row['ticket_response_sla_met'] !== null && intval($row['ticket_response_sla_met']) === 0;
return ''
. reportDuration($row['reply_seconds']) . ($late ? ' ' : '') . '';
}],
'resolve_seconds' => ['label' => 'To resolve', 'type' => 'duration', 'html' => function ($row) {
$late = $row['ticket_resolution_sla_met'] !== null && intval($row['ticket_resolution_sla_met']) === 0;
return ''
. reportDuration($row['resolve_seconds']) . ($late ? ' ' : '') . '';
}],
'worked_seconds' => ['label' => 'Time logged', 'type' => 'hours', 'decimals' => 1],
'ticket_closed_at' => ['label' => 'Closed', 'type' => 'date'],
];
$month_columns = ['when' => ['label' => ucfirst($buckets['unit']), 'sort' => false]] + $figure_columns;
$month_rows = [];
foreach ($buckets['keys'] as $i => $key) {
if (isset($by_bucket[$key])) {
$month_rows[] = ['when' => $buckets['labels'][$i]] + $by_bucket[$key];
}
}
if (reportExporting('agents')) {
reportCsv('Satisfaction by Agent', $agent_columns, $agent_rows, ['agent' => 'Total'] + $overall);
}
if (reportExporting('clients')) {
reportCsv('Satisfaction by Client', $client_columns, $client_rows, ['client' => 'Total'] + $overall);
}
if (reportExporting('bad')) {
reportCsv('Bad Ratings', $bad_columns, $bad_rows);
}
if (reportExporting()) {
reportCsv('Satisfaction by ' . ucfirst($buckets['unit']), $month_columns, $month_rows, ['when' => 'Total'] + $overall);
}
require_once "includes/inc_report_layout.php";
$bad_clients = count(array_unique(array_column($bad_rows, 'ticket_client_id')));
$was = function ($value) use ($period) {
return $value === null ? '' : 'Was ' . reportPercent($value) . ' ' . escapeHtml($period['compare']) . '';
};
?>
$period,
'years' => reportPastYears("SELECT DISTINCT YEAR(ticket_closed_at) AS y FROM tickets WHERE ticket_feedback IS NOT NULL AND ticket_closed_at IS NOT NULL"),
'client' => $client_id,
'agent' => $agent_id,
'export' => ['months' => 'By ' . $buckets['unit'], 'agents' => 'By agent', 'clients' => 'By client', 'bad' => 'Bad ratings'],
]); ?>
= reportStats([
['label' => 'Satisfaction', 'value_html' => reportRateHtml($overall['score']), 'title' => 'Good ratings, of the good and bad', 'sub' => $was($previous['score'])],
['label' => 'Ratings', 'value' => reportCount($overall['rated']),
'sub' => $overall['closed'] ? reportPercent($overall['rated_percent']) . '
of ' . reportCount($overall['closed']) . ' closed tickets' : ''],
['label' => 'Rated good', 'value' => reportCount($overall['good']), 'tone' => $overall['good'] ? 'success' : '',
'sub' => $previous['rated'] ? reportChangeHtml($overall['good'], $previous['good'], 'up', $period['compare']) : ''],
['label' => 'Rated bad', 'value' => reportCount($overall['bad']), 'tone' => $overall['bad'] ? 'danger' : 'success',
'sub' => $overall['bad'] ? reportCount($bad_clients) . '
' . ($bad_clients === 1 ? 'client' : 'clients') . ' to follow up' : '
Nothing to follow up'],
]) ?>
= reportEmpty('No ticket closed in this period was rated. Contacts rate a ticket Good or Bad in the client portal once it is closed.', 'fa-smile', 'secondary') ?>
= reportSectionOpen('Ratings over the period', 'Bars are ratings, the line the share rated good') ?>
'bar',
'labels' => $buckets['labels'],
'stacked' => true,
'series' => [
['label' => 'Good', 'data' => array_map(function ($k) use ($by_bucket) { return $by_bucket[$k]['good'] ?? 0; }, $keys), 'tone' => 'success', 'stack' => 'ratings'],
['label' => 'Bad', 'data' => array_map(function ($k) use ($by_bucket) { return $by_bucket[$k]['bad'] ?? 0; }, $keys), 'tone' => 'danger', 'stack' => 'ratings'],
['label' => 'Satisfaction', 'data' => array_map(function ($k) use ($by_bucket) { return $by_bucket[$k]['score'] ?? null; }, $keys), 'tone' => 'accent', 'type' => 'line', 'axis' => 'y2'],
],
'format' => 'count',
'format2' => 'percent',
'max2' => 100,
], '', 'Good and bad ratings and the share rated good over the period');
?>
= reportSectionOpen('What the rating follows', 'Satisfaction by how the ticket went') ?>
= reportTable($driver_columns, $driver_rows, ['sortable' => false]) ?>
= reportSectionOpen('By agent', 'Who the ticket was assigned to') ?>
= reportTable($agent_columns, $agent_rows, ['totals' => ['agent' => 'Total'] + $overall, 'totals_html' => ['score' => reportRateHtml($overall['score'])]]) ?>
= reportSectionOpen('By client', 'The most bad ratings first') ?>
= reportTable($client_columns, $client_rows, ['limit' => 50, 'export_table' => 'clients', 'totals' => ['client' => 'Total'] + $overall, 'totals_html' => ['score' => reportRateHtml($overall['score'])]]) ?>
= reportSectionOpen('Bad ratings', $bad_rows ? 'Newest first - worth a call' : '') ?>
= reportTable($bad_columns, $bad_rows, ['limit' => 100, 'export_table' => 'bad', 'empty' => reportEmpty('No bad ratings in this period.', 'fa-smile', 'success')]) ?>
Tickets closed in the period. Contacts rate a ticket Good or Bad in the client portal once it is closed; satisfaction is the share rated good.
Times run on the clock from when the ticket was raised; a red time missed its SLA target.
0) { ?>
= reportCount($other) ?> = $other === 1 ? 'rating has' : 'ratings have' ?> another value and = $other === 1 ? 'is' : 'are' ?> counted as rated, but neither good nor bad.