mirror of
https://github.com/itflow-org/itflow
synced 2026-08-04 22:57:14 +00:00
Major UI/UX overhaul on Tickets listing / Kanban and ticket details
This commit is contained in:
62
agent/css/ticket.css
Normal file
62
agent/css/ticket.css
Normal file
@@ -0,0 +1,62 @@
|
||||
/* Ticket detail page */
|
||||
|
||||
/*
|
||||
* Header property fields.
|
||||
*
|
||||
* These used to be a run of inline label/value pairs separated only by a right
|
||||
* margin, which read as one crunched line and collided outright when it wrapped.
|
||||
* A fixed minimum width and a shared gutter give the row a rhythm, so each
|
||||
* value reads as its own field and a wrapped row keeps its spacing.
|
||||
*/
|
||||
.ticket-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -0.85rem;
|
||||
}
|
||||
|
||||
.ticket-field {
|
||||
flex: 0 0 auto;
|
||||
min-width: 150px;
|
||||
max-width: 100%;
|
||||
padding: 0 0.85rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.ticket-field-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #8a94a0;
|
||||
margin-bottom: 0.2rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-field-value {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* The subject is the page's real heading, so let it breathe */
|
||||
.ticket-subject {
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* The pencil next to the subject shouldn't drag the heading's line height around */
|
||||
.ticket-subject .btn-tool {
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ticket-meta {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.ticket-field {
|
||||
min-width: 45%;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +1,191 @@
|
||||
/* General Popover Styling */
|
||||
.popover {
|
||||
max-width: 600px;
|
||||
}
|
||||
/* Ticket kanban board */
|
||||
|
||||
/* Kanban Board Container */
|
||||
#kanban-board {
|
||||
/* Board container - columns scroll sideways, each column scrolls on its own */
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
min-width: 400px;
|
||||
height: calc(100vh - 210px);
|
||||
}
|
||||
|
||||
/* Kanban Column */
|
||||
/* Column */
|
||||
.kanban-column {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
flex: 0 0 300px;
|
||||
max-width: 300px;
|
||||
margin: 0 10px;
|
||||
background: #f4f4f4;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
min-height: calc(100vh - 230px);
|
||||
max-height: calc(100vh - 230px);
|
||||
background: #f1f3f5;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 300px);
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
/* Column Inner Scrollable Task Area */
|
||||
.kanban-status {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 60px;
|
||||
position: relative;
|
||||
padding: 5px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Individual Task Cards */
|
||||
.task {
|
||||
/* Column header - doubles as the drag handle for reordering columns */
|
||||
.kanban-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #212529;
|
||||
background: #fff;
|
||||
margin: 5px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border-top: 3px solid #6c757d;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
border-radius: 5px 5px 0 0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Grabbing Cursor State */
|
||||
.task:active {
|
||||
.kanban-column-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Drag Handle (shown on mobile or with class targeting) */
|
||||
.drag-handle-class {
|
||||
float: right;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
.kanban-column-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Placeholder shown in empty columns */
|
||||
/* Scrollable card area */
|
||||
.kanban-status {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
min-height: 60px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.kanban-card {
|
||||
background: #fff;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-left: 3px solid #adb5bd;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.kanban-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
border-color: #adb5bd;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.kanban-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* SLA state - the left edge carries it so it reads at a glance down a column */
|
||||
.kanban-card-at-risk {
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
.kanban-card-breached {
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
|
||||
/* Card top row - number, priority, SLA flag, drag handle */
|
||||
.kanban-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.kanban-card-number {
|
||||
font-weight: 700;
|
||||
color: #343a40;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.kanban-card-number:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.kanban-card-subject {
|
||||
display: block;
|
||||
color: #212529;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kanban-card-client {
|
||||
color: #495057;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kanban-card-meta {
|
||||
color: #6c757d;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kanban-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed #e9ecef;
|
||||
color: #6c757d;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Drag handle - hidden until hover, and forced visible on touch by the JS */
|
||||
.drag-handle-class {
|
||||
color: #adb5bd;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.kanban-card:hover .drag-handle-class {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Empty column placeholder */
|
||||
.empty-placeholder {
|
||||
border: 2px dashed #ccc;
|
||||
border: 2px dashed #ced4da;
|
||||
background-color: #fcfcfc;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
color: #adb5bd;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Sortable drop zone feedback (optional visual cue) */
|
||||
/* Drop zone feedback */
|
||||
.kanban-status.sortable-over {
|
||||
background-color: #eaf6ff;
|
||||
background-color: #e7f1ff;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Popovers on this page can carry a fair amount of ticket detail */
|
||||
.popover {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.kanban-column {
|
||||
flex-basis: 260px;
|
||||
max-width: 260px;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,8 @@ function copyPasswordViaCredentialID(button, credential_id) {
|
||||
const credential = JSON.parse(data);
|
||||
|
||||
navigator.clipboard.writeText(credential.password).then(function() {
|
||||
// Flash "Copied!" tooltip, matching the ClipboardJS success handler in app.js
|
||||
jQuery(button).tooltip('hide')
|
||||
.attr('data-original-title', 'Copied!')
|
||||
.tooltip('show');
|
||||
|
||||
setTimeout(function() {
|
||||
jQuery(button).tooltip('hide');
|
||||
}, 1000);
|
||||
// Same "Copied!" flash the ClipboardJS handler in app.js uses
|
||||
flashTooltip(button, 'Copied!');
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
$(document).ready(function () {
|
||||
console.log('CONFIG_TICKET_MOVING_COLUMNS:', CONFIG_TICKET_MOVING_COLUMNS);
|
||||
console.log('CONFIG_TICKET_ORDERING:', CONFIG_TICKET_ORDERING);
|
||||
|
||||
// -------------------------------
|
||||
// Drag: Kanban Columns (Statuses)
|
||||
// -------------------------------
|
||||
new Sortable(document.querySelector('#kanban-board'), {
|
||||
animation: 150,
|
||||
handle: '.panel-title',
|
||||
handle: '.kanban-column-header',
|
||||
draggable: '.kanban-column',
|
||||
onEnd: function () {
|
||||
const columnPositions = Array.from(document.querySelectorAll('#kanban-board .kanban-column')).map((col, index) => ({
|
||||
@@ -19,8 +16,6 @@ $(document).ready(function () {
|
||||
$.post('ajax.php', {
|
||||
update_kanban_status_position: true,
|
||||
positions: columnPositions
|
||||
}).done(() => {
|
||||
console.log('Ticket status kanban orders updated.');
|
||||
}).fail((xhr) => {
|
||||
console.error('Error updating status order:', xhr.responseText);
|
||||
});
|
||||
@@ -35,6 +30,9 @@ $(document).ready(function () {
|
||||
new Sortable(statusCol, {
|
||||
group: 'tickets',
|
||||
animation: 150,
|
||||
// Let the ticket number / subject links work without starting a drag
|
||||
filter: 'a',
|
||||
preventOnFilter: false,
|
||||
handle: isTouchDevice() ? '.drag-handle-class' : undefined,
|
||||
onStart: () => hidePlaceholders(),
|
||||
onEnd: function (evt) {
|
||||
@@ -69,8 +67,6 @@ $(document).ready(function () {
|
||||
$.post('ajax.php', {
|
||||
update_kanban_ticket: true,
|
||||
positions: positions
|
||||
}).done(() => {
|
||||
console.log('Updated kanban ticket positions.');
|
||||
}).fail((xhr) => {
|
||||
console.error('Error updating ticket positions:', xhr.responseText);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ $q_filter = $_GET['q'] ?? '';
|
||||
|
||||
// Status Filter - the page uses an ID set, or the Open / Closed shorthand
|
||||
$status_filter = (isset($_GET['status']) && is_array($_GET['status'])) ? array_map('intval', $_GET['status']) : [];
|
||||
$status_shorthand = (isset($_GET['status']) && !is_array($_GET['status'])) ? $_GET['status'] : '';
|
||||
// Coarse open / closed / all state. `status` scalar is the pre-rework name.
|
||||
$state_filter = $_GET['state'] ?? '';
|
||||
if (!in_array($state_filter, array('open', 'closed', 'all'), true)) {
|
||||
$state_filter = (isset($_GET['status']) && !is_array($_GET['status']) && strtolower($_GET['status']) === 'closed') ? 'closed' : 'open';
|
||||
}
|
||||
|
||||
// Client Filter
|
||||
$client_filter = intval($_GET['client'] ?? 0);
|
||||
@@ -30,6 +34,9 @@ $assigned_filter = intval($_GET['assigned'] ?? 0);
|
||||
// SLA Filter
|
||||
$sla_filter = $_GET['sla'] ?? '';
|
||||
|
||||
// Billing Filter
|
||||
$billing_filter = $_GET['billing'] ?? '';
|
||||
|
||||
// Date Filter - the all-time sentinels from filter_header.php leave the fields blank
|
||||
$date_from_filter = (!empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') ? escapeHtml($_GET['dtf']) : '';
|
||||
$date_to_filter = (!empty($_GET['dtt']) && $_GET['dtt'] !== '2099-12-31') ? escapeHtml($_GET['dtt']) : '';
|
||||
@@ -94,12 +101,30 @@ ob_start();
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-check"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" name="resolution">
|
||||
<option <?php if ($status_shorthand !== 'Closed') { echo "selected"; } ?> value="">Open</option>
|
||||
<option <?php if ($status_shorthand === 'Closed') { echo "selected"; } ?> value="Closed">Closed</option>
|
||||
<option <?php if ($state_filter === 'open') { echo "selected"; } ?> value="">Open</option>
|
||||
<option <?php if ($state_filter === 'closed') { echo "selected"; } ?> value="Closed">Closed</option>
|
||||
<option <?php if ($state_filter === 'all') { echo "selected"; } ?> value="All">Open and closed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($config_module_enable_accounting && lookupUserPermission("module_sales") >= 2) { ?>
|
||||
<div class="form-group">
|
||||
<label>Billing</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa fa-fw fa-dollar-sign"></i></span>
|
||||
</div>
|
||||
<select class="form-control select2" name="billing">
|
||||
<option value="">- Any -</option>
|
||||
<option <?php if ($billing_filter === 'unbilled') { echo "selected"; } ?> value="unbilled">Billable, not invoiced</option>
|
||||
<option <?php if ($billing_filter === 'invoiced') { echo "selected"; } ?> value="invoiced">Invoiced</option>
|
||||
<option <?php if ($billing_filter === 'nonbillable') { echo "selected"; } ?> value="nonbillable">Not billable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php if (!$client_id) { ?>
|
||||
<div class="form-group">
|
||||
<label>Client</label>
|
||||
|
||||
@@ -2742,19 +2742,28 @@ if (isset($_POST['export_tickets'])) {
|
||||
} elseif (!empty($_POST['resolution']) && $_POST['resolution'] == 'Closed') {
|
||||
$ticket_status_snippet = "ticket_resolved_at IS NOT NULL";
|
||||
$filter_summary['Status'] = 'Closed';
|
||||
} elseif (!empty($_POST['resolution']) && $_POST['resolution'] == 'All') {
|
||||
$ticket_status_snippet = "1 = 1";
|
||||
$filter_summary['Status'] = 'Open and closed';
|
||||
} else {
|
||||
// Default - open tickets
|
||||
$ticket_status_snippet = "ticket_resolved_at IS NULL";
|
||||
$filter_summary['Status'] = 'Open';
|
||||
}
|
||||
|
||||
// Billable / unbilled Filter - overrides the status snippet, same as the page
|
||||
if (isset($_POST['billable']) && $_POST['billable'] == 1 && isset($_POST['unbilled'])) {
|
||||
$ticket_billable_snippet = "AND ticket_billable = 1 AND ticket_invoice_id = 0";
|
||||
$ticket_status_snippet = '1 = 1';
|
||||
$filter_summary['Billable'] = 'Billable, not yet invoiced';
|
||||
} else {
|
||||
$ticket_billable_snippet = '';
|
||||
// Billing Filter - same values the tickets page offers
|
||||
$ticket_billable_snippet = '';
|
||||
if (!empty($_POST['billing'])) {
|
||||
if ($_POST['billing'] == 'unbilled') {
|
||||
$ticket_billable_snippet = "AND ticket_billable = 1 AND ticket_invoice_id = 0";
|
||||
$filter_summary['Billing'] = 'Billable, not invoiced';
|
||||
} elseif ($_POST['billing'] == 'invoiced') {
|
||||
$ticket_billable_snippet = "AND ticket_invoice_id > 0";
|
||||
$filter_summary['Billing'] = 'Invoiced';
|
||||
} elseif ($_POST['billing'] == 'nonbillable') {
|
||||
$ticket_billable_snippet = "AND ticket_billable = 0";
|
||||
$filter_summary['Billing'] = 'Not billable';
|
||||
}
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
|
||||
2002
agent/ticket.php
2002
agent/ticket.php
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,28 @@
|
||||
<link rel="stylesheet" href="css/ticket_kanban.css">
|
||||
|
||||
<?php
|
||||
/*
|
||||
* Ticket kanban view
|
||||
*
|
||||
* Included by tickets.php, which owns the filters and sets the query parts
|
||||
* this page runs. Should not be accessed directly.
|
||||
*/
|
||||
|
||||
isset($mysqli) || die("Direct file access is not allowed");
|
||||
|
||||
// Fetch statuses
|
||||
$status_sql = mysqli_query($mysqli, "SELECT * FROM ticket_statuses WHERE ticket_status_active = 1 AND ticket_status_id != 5 ORDER BY ticket_status_order");
|
||||
/*
|
||||
* Columns are the active ticket statuses. Closed (5) is excluded - a board
|
||||
* column of every ticket ever closed is not something anyone drags between.
|
||||
*/
|
||||
$status_sql = mysqli_query($mysqli, "SELECT ticket_status_id, ticket_status_name, ticket_status_color, ticket_status_order, ticket_status_pauses_sla FROM ticket_statuses WHERE ticket_status_active = 1 AND ticket_status_id != 5 ORDER BY ticket_status_order");
|
||||
|
||||
$statuses = array();
|
||||
|
||||
while ($row = mysqli_fetch_assoc($status_sql)) {
|
||||
|
||||
$status_id = intval($row['ticket_status_id']);
|
||||
$status_name = escapeHtml($row['ticket_status_name']);
|
||||
$kanban_order = intval($row['ticket_status_order']);
|
||||
|
||||
$statuses[$status_id] = array(
|
||||
'id' => $status_id,
|
||||
'name' => $status_name,
|
||||
'order' => $kanban_order,
|
||||
'tickets' => array()
|
||||
'id' => $status_id,
|
||||
'name' => escapeHtml($row['ticket_status_name']),
|
||||
'color' => escapeHtml($row['ticket_status_color']),
|
||||
'pauses_sla' => intval($row['ticket_status_pauses_sla']),
|
||||
'tickets' => array()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,74 +40,88 @@ if ($config_ticket_ordering == 1) {
|
||||
$ordering_snippet = "ORDER BY ticket_order ASC";
|
||||
}
|
||||
|
||||
// Fetch tickets
|
||||
$sql = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT SQL_CALC_FOUND_ROWS * 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
|
||||
LEFT JOIN assets ON ticket_asset_id = asset_id
|
||||
LEFT JOIN locations ON ticket_location_id = location_id
|
||||
LEFT JOIN vendors ON ticket_vendor_id = vendor_id
|
||||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
LEFT JOIN categories ON ticket_category = category_id
|
||||
WHERE $ticket_status_snippet $ticket_assigned_query
|
||||
$category_query
|
||||
$client_query
|
||||
AND DATE(ticket_created_at) BETWEEN '$dtf' AND '$dtt'
|
||||
AND (
|
||||
CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR
|
||||
client_name LIKE '%$q%' OR
|
||||
ticket_subject LIKE '%$q%' OR
|
||||
ticket_status_name LIKE '%$q%' OR
|
||||
ticket_priority LIKE '%$q%' OR
|
||||
user_name LIKE '%$q%' OR
|
||||
contact_name LIKE '%$q%' OR
|
||||
asset_name LIKE '%$q%' OR
|
||||
vendor_name LIKE '%$q%' OR
|
||||
ticket_vendor_ticket_number LIKE '%$q%'
|
||||
)
|
||||
$ticket_project_snippet
|
||||
$access_permission_query_overide
|
||||
/*
|
||||
* Same WHERE clause the list view runs, so the filter bar means the same
|
||||
* thing in both views. The old kanban query built its own subset and quietly
|
||||
* ignored the SLA, priority and billing filters.
|
||||
*/
|
||||
$sql = mysqli_query($mysqli,
|
||||
"SELECT $ticket_select_columns
|
||||
FROM tickets
|
||||
$ticket_joins
|
||||
$ticket_where
|
||||
$ordering_snippet"
|
||||
);
|
||||
|
||||
$kanban_ticket_count = 0;
|
||||
$kanban_offboard_count = 0;
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
|
||||
$status_id = $row['ticket_status_id'];
|
||||
|
||||
foreach ($row as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$row[$key] = escapeHtml($value);
|
||||
}
|
||||
}
|
||||
|
||||
$status_id = intval($row['ticket_status_id']);
|
||||
if (isset($statuses[$status_id])) {
|
||||
$statuses[$status_id]['tickets'][] = $row;
|
||||
$kanban_ticket_count++;
|
||||
} else {
|
||||
// Matched the filters but sits in a status with no column - Closed, or
|
||||
// a status that has since been deactivated
|
||||
$kanban_offboard_count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert associative array to indexed array
|
||||
$kanban = array_values($statuses);
|
||||
|
||||
?>
|
||||
|
||||
<div class="container-fluid" id="kanban-board">
|
||||
<link rel="stylesheet" href="css/ticket_kanban.css">
|
||||
|
||||
<?php if (!$kanban_ticket_count) { ?>
|
||||
<div class="card card-dark">
|
||||
<div class="card-body text-center text-secondary py-5">
|
||||
<i class="fas fa-fw fa-columns fa-2x mb-3"></i>
|
||||
<?php if ($kanban_offboard_count) { ?>
|
||||
<p class="mb-1">The board has no column for these tickets - closed tickets are not shown on the kanban.</p>
|
||||
<a href="<?= ticketsFilterUrl(['view' => 'list']) ?>">Show them in the list</a>
|
||||
<?php } else { ?>
|
||||
<p class="mb-1">No tickets match these filters.</p>
|
||||
<?php if ($active_filters) { ?>
|
||||
<a href="<?= escapeHtml('?' . http_build_query(array_filter(array('client_id' => $_GET['client_id'] ?? null, 'view' => 'kanban')))) ?>">Clear the filters</a>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php } elseif ($kanban_offboard_count) { ?>
|
||||
<div class="alert alert-secondary py-2">
|
||||
<i class="fas fa-fw fa-info-circle mr-1"></i>
|
||||
<?= $kanban_offboard_count ?> matching ticket<?= $kanban_offboard_count == 1 ? ' is' : 's are' ?> not shown - the board has no column for closed tickets.
|
||||
<a href="<?= ticketsFilterUrl(['view' => 'list']) ?>">Show them in the list</a>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="kanban-board" id="kanban-board">
|
||||
|
||||
<?php foreach ($kanban as $column) { ?>
|
||||
|
||||
<div class="kanban-column card card-dark" data-status-id="<?= $column['id'] ?>">
|
||||
<h6 class="panel-title"><?= $column['name'] ?></h6>
|
||||
<div class="kanban-column" data-status-id="<?= $column['id'] ?>">
|
||||
|
||||
<div class="kanban-column-header" style="border-top-color: <?= $column['color'] ?>">
|
||||
<span class="kanban-column-name"><?= $column['name'] ?></span>
|
||||
<span class="badge badge-pill badge-secondary ml-1"><?= count($column['tickets']) ?></span>
|
||||
<?php if ($column['pauses_sla']) { ?>
|
||||
<i class="fas fa-fw fa-pause text-secondary ml-1" title="The resolution SLA clock is paused in this status"></i>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<div class="kanban-status" data-column-name="<?= $column['name'] ?>" data-status-id="<?= $column['id'] ?>">
|
||||
|
||||
<?php foreach ($column['tickets'] as $item) {
|
||||
|
||||
if ($item['ticket_priority'] == "Urgent") {
|
||||
$item_ticket_id = intval($item['ticket_id']);
|
||||
$item_priority = escapeHtml($item['ticket_priority']);
|
||||
|
||||
if ($item_priority == "Urgent") {
|
||||
$ticket_priority_color = "dark";
|
||||
} elseif ($item['ticket_priority'] == "High") {
|
||||
} elseif ($item_priority == "High") {
|
||||
$ticket_priority_color = "danger";
|
||||
} elseif ($item['ticket_priority'] == "Medium") {
|
||||
} elseif ($item_priority == "Medium") {
|
||||
$ticket_priority_color = "warning";
|
||||
} else {
|
||||
$ticket_priority_color = "info";
|
||||
@@ -117,64 +135,69 @@ $kanban = array_values($statuses);
|
||||
}
|
||||
$ticket_sla_class = '';
|
||||
if ($ticket_sla_alert_stage == 2) {
|
||||
$ticket_sla_class = ' border-danger';
|
||||
$ticket_sla_class = ' kanban-card-breached';
|
||||
} elseif ($ticket_sla_alert_stage == 1) {
|
||||
$ticket_sla_class = ' border-warning';
|
||||
$ticket_sla_class = ' kanban-card-at-risk';
|
||||
}
|
||||
|
||||
$item_client_id = intval($item['ticket_client_id']);
|
||||
$item_has_client = $item_client_id ? "&client_id=$item_client_id" : "";
|
||||
?>
|
||||
|
||||
<div class="task grab-cursor<?= $ticket_sla_class ?>"
|
||||
data-ticket-id="<?= $item['ticket_id'] ?>"
|
||||
data-ticket-status-id="<?= $item['ticket_status_id'] ?>"
|
||||
ondblclick="window.location.href='ticket.php?ticket_id=<?= $item['ticket_id'] ?>'">
|
||||
<div class="task kanban-card<?= $ticket_sla_class ?>"
|
||||
data-ticket-id="<?= $item_ticket_id ?>"
|
||||
data-ticket-status-id="<?= intval($item['ticket_status_id']) ?>">
|
||||
|
||||
<span class="badge badge-<?= $ticket_priority_color ?>">
|
||||
<?= $item['ticket_priority'] ?>
|
||||
</span>
|
||||
<div class="kanban-card-top">
|
||||
<a href="ticket.php?ticket_id=<?= $item_ticket_id . $item_has_client ?>" class="kanban-card-number">
|
||||
<?= escapeHtml($item['ticket_prefix']) . intval($item['ticket_number']) ?>
|
||||
</a>
|
||||
|
||||
<?php if ($ticket_sla_alert_stage == 2) { ?>
|
||||
<span class="badge badge-danger" title="SLA breached"><i class="fas fa-fw fa-stopwatch"></i></span>
|
||||
<?php } elseif ($ticket_sla_alert_stage == 1) { ?>
|
||||
<span class="badge badge-warning" title="SLA at risk"><i class="fas fa-fw fa-stopwatch"></i></span>
|
||||
<?php } ?>
|
||||
<span class="badge badge-<?= $ticket_priority_color ?>"><?= $item_priority ?></span>
|
||||
|
||||
<span class="badge badge-secondary">
|
||||
<?= $item['category_name'] ?>
|
||||
</span>
|
||||
<?php if ($ticket_sla_alert_stage == 2) { ?>
|
||||
<span class="badge badge-danger" title="SLA breached"><i class="fas fa-fw fa-stopwatch"></i></span>
|
||||
<?php } elseif ($ticket_sla_alert_stage == 1) { ?>
|
||||
<span class="badge badge-warning" title="SLA at risk"><i class="fas fa-fw fa-stopwatch"></i></span>
|
||||
<?php } ?>
|
||||
|
||||
<div class="btn btn-light drag-handle-class" style="display:none;">
|
||||
<i class="drag-handle-class fas fa-bars"></i>
|
||||
<span class="drag-handle-class" title="Drag to move"><i class="fas fa-grip-vertical"></i></span>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<a href="ticket.php?ticket_id=<?= $item_ticket_id . $item_has_client ?>" class="kanban-card-subject">
|
||||
<?= escapeHtml($item['ticket_subject']) ?>
|
||||
</a>
|
||||
|
||||
<b>
|
||||
<div class="kanban-card-client">
|
||||
<?php
|
||||
if (!$client_url) {
|
||||
if ($item['contact_name'] != "") {
|
||||
echo $item['client_name'] . ' - ' . $item['contact_name'];
|
||||
} else {
|
||||
echo $item['client_name'];
|
||||
echo escapeHtml($item['client_name']);
|
||||
if (!empty($item['contact_name'])) {
|
||||
echo ' <span class="text-secondary">· ' . escapeHtml($item['contact_name']) . '</span>';
|
||||
}
|
||||
} else {
|
||||
echo $item['contact_name'];
|
||||
echo escapeHtml($item['contact_name']);
|
||||
}
|
||||
?>
|
||||
</b>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<?php if ($item['asset_name'] != "") { ?>
|
||||
<i class="fa fa-fw fa-desktop text-secondary mr-2"></i><?= $item['asset_name'] ?><br>
|
||||
<?php if (!empty($item['asset_name'])) { ?>
|
||||
<div class="kanban-card-meta"><i class="fa fa-fw fa-desktop mr-1"></i><?= escapeHtml($item['asset_name']) ?></div>
|
||||
<?php } ?>
|
||||
|
||||
<i class="fa fa-fw fa-life-ring text-secondary mr-2"></i><?= $item['ticket_subject'] ?><br>
|
||||
<div class="kanban-card-footer">
|
||||
<span title="Assigned to">
|
||||
<?php if (!empty($item['user_name'])) { ?>
|
||||
<i class="fas fa-fw fa-user mr-1"></i><?= escapeHtml($item['user_name']) ?>
|
||||
<?php } else { ?>
|
||||
<i class="fas fa-fw fa-user-slash mr-1 text-danger"></i><span class="text-danger">Unassigned</span>
|
||||
<?php } ?>
|
||||
</span>
|
||||
<span title="Created <?= escapeHtml($item['ticket_created_at']) ?>"><?= timeAgo($item['ticket_created_at']) ?></span>
|
||||
</div>
|
||||
|
||||
<i class="fas fa-fw fa-user mr-2 text-secondary"></i><?= $item['user_name'] ?><br>
|
||||
|
||||
<?php if ($item['ticket_schedule'] != "") { ?>
|
||||
<i class="fa fa-fw fa-calendar-check text-secondary mr-2"></i>
|
||||
<span class="badge badge-warning"><?= $item['ticket_schedule'] ?></span>
|
||||
<?php if (!empty($item['ticket_schedule'])) { ?>
|
||||
<div class="kanban-card-meta"><i class="fa fa-fw fa-calendar-check mr-1"></i><span class="badge badge-warning"><?= escapeHtml($item['ticket_schedule']) ?></span></div>
|
||||
<?php } ?>
|
||||
|
||||
</div>
|
||||
@@ -188,12 +211,10 @@ $kanban = array_values($statuses);
|
||||
|
||||
</div>
|
||||
|
||||
<?php
|
||||
echo "<script>";
|
||||
echo "const CONFIG_TICKET_MOVING_COLUMNS = " . json_encode($config_ticket_moving_columns) . ";";
|
||||
echo "const CONFIG_TICKET_ORDERING = " . json_encode($config_ticket_ordering) . ";";
|
||||
echo "</script>";
|
||||
?>
|
||||
<script>
|
||||
const CONFIG_TICKET_MOVING_COLUMNS = <?= json_encode($config_ticket_moving_columns) ?>;
|
||||
const CONFIG_TICKET_ORDERING = <?= json_encode($config_ticket_ordering) ?>;
|
||||
</script>
|
||||
|
||||
<script src="../libs/SortableJS/Sortable.min.js"></script>
|
||||
<script src="js/tickets_kanban.js"></script>
|
||||
|
||||
@@ -1,18 +1,154 @@
|
||||
<?php
|
||||
/*
|
||||
* Ticket list view
|
||||
*
|
||||
* Included by tickets.php, which owns the filters and sets the query parts
|
||||
* this page runs. Should not be accessed directly.
|
||||
*/
|
||||
|
||||
isset($mysqli) || die("Direct file access is not allowed");
|
||||
|
||||
$query =
|
||||
"SELECT SQL_CALC_FOUND_ROWS $ticket_select_columns
|
||||
FROM tickets
|
||||
$ticket_joins
|
||||
$ticket_where
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN '$sort' = 'ticket_priority' THEN
|
||||
CASE ticket_priority
|
||||
WHEN 'Urgent' THEN 0
|
||||
WHEN 'High' THEN 1
|
||||
WHEN 'Medium' THEN 2
|
||||
WHEN 'Low' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
ELSE NULL
|
||||
END $order,
|
||||
$sort $order
|
||||
LIMIT $record_from, $record_to";
|
||||
|
||||
$sql = mysqli_query($mysqli, $query);
|
||||
|
||||
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
|
||||
|
||||
/*
|
||||
* Collect the page before rendering so the per-row lookups can be done as two
|
||||
* grouped queries instead of three per ticket. A 100-row page used to fire
|
||||
* 300 extra queries to fill in the last reply and the task progress bar.
|
||||
*/
|
||||
$tickets = array();
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$tickets[intval($row['ticket_id'])] = $row;
|
||||
}
|
||||
|
||||
$ticket_replies = array();
|
||||
$ticket_tasks = array();
|
||||
|
||||
if ($tickets) {
|
||||
$page_ticket_ids = implode(',', array_keys($tickets));
|
||||
|
||||
// Latest non-archived reply per ticket, for the Last Response column
|
||||
$sql_ticket_replies = mysqli_query($mysqli,
|
||||
"SELECT ticket_reply_ticket_id, ticket_reply_type, ticket_reply_created_at, contact_name, user_name
|
||||
FROM ticket_replies
|
||||
LEFT JOIN users ON ticket_reply_by = user_id
|
||||
LEFT JOIN contacts ON ticket_reply_by = contact_id
|
||||
WHERE ticket_reply_ticket_id IN ($page_ticket_ids)
|
||||
AND ticket_reply_archived_at IS NULL
|
||||
AND ticket_reply_id IN (
|
||||
SELECT MAX(ticket_reply_id) FROM ticket_replies
|
||||
WHERE ticket_reply_ticket_id IN ($page_ticket_ids)
|
||||
AND ticket_reply_archived_at IS NULL
|
||||
GROUP BY ticket_reply_ticket_id
|
||||
)"
|
||||
);
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_replies)) {
|
||||
$ticket_replies[intval($row['ticket_reply_ticket_id'])] = $row;
|
||||
}
|
||||
|
||||
// Task counts per ticket
|
||||
$sql_ticket_tasks = mysqli_query($mysqli,
|
||||
"SELECT task_ticket_id, COUNT(task_id) AS task_count,
|
||||
SUM(CASE WHEN task_completed_at IS NOT NULL THEN 1 ELSE 0 END) AS completed_count
|
||||
FROM tasks
|
||||
WHERE task_ticket_id IN ($page_ticket_ids)
|
||||
GROUP BY task_ticket_id"
|
||||
);
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_tasks)) {
|
||||
$ticket_tasks[intval($row['task_ticket_id'])] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="card card-dark">
|
||||
<div class="card-body">
|
||||
<form id="bulkActions" action="post.php" method="post">
|
||||
|
||||
<?php if (!$tickets) { ?>
|
||||
|
||||
<div class="text-center text-secondary py-5">
|
||||
<i class="fas fa-fw fa-life-ring fa-2x mb-3"></i>
|
||||
<p class="mb-1">No tickets match these filters.</p>
|
||||
<?php if ($active_filters) { ?>
|
||||
<a href="<?= escapeHtml('?' . http_build_query(array_filter(array('client_id' => $_GET['client_id'] ?? null, 'view' => 'list')))) ?>">Clear the filters</a>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<?php } else { ?>
|
||||
|
||||
<form id="bulkActions" action="post.php" method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
|
||||
<?php if (lookupUserPermission("module_support") >= 2) { ?>
|
||||
<!-- Bulk actions - sits with the rows it acts on, and only appears once something is ticked -->
|
||||
<div class="mb-2" id="bulkActionButton" hidden>
|
||||
<div class="dropdown d-inline-block">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<i class="fas fa-fw fa-layer-group mr-2"></i><span id="selectedCount">0</span> selected
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_bulk_assign.php" data-bulk="true">
|
||||
<i class="fas fa-fw fa-user-check mr-2"></i>Assign Agent
|
||||
</a>
|
||||
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_bulk_edit_priority.php" data-bulk="true">
|
||||
<i class="fas fa-fw fa-thermometer-half mr-2"></i>Set Priority
|
||||
</a>
|
||||
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_bulk_edit_category.php" data-bulk="true">
|
||||
<i class="fas fa-fw fa-layer-group mr-2"></i>Set Category
|
||||
</a>
|
||||
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_bulk_add_project.php" data-bulk="true">
|
||||
<i class="fas fa-fw fa-project-diagram mr-2"></i>Set Project
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_bulk_reply.php" data-modal-size="lg" data-bulk="true">
|
||||
<i class="fas fa-fw fa-paper-plane mr-2"></i>Update/Reply
|
||||
</a>
|
||||
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_bulk_merge.php" data-bulk="true">
|
||||
<i class="fas fa-fw fa-clone mr-2"></i>Merge
|
||||
</a>
|
||||
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/ticket/ticket_bulk_resolve.php" data-modal-size="lg" data-bulk="true">
|
||||
<i class="fas fa-fw fa-check mr-2"></i>Resolve
|
||||
</a>
|
||||
<?php if (lookupUserPermission("module_support") === 3) { ?>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger text-bold confirm-link" type="submit" form="bulkActions" name="bulk_delete_tickets">
|
||||
<i class="fas fa-fw fa-trash mr-2"></i>Delete
|
||||
</button>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-borderless table-hover">
|
||||
<thead class="text-dark <?php if (!$num_rows[0]) { echo "d-none"; } ?> text-nowrap">
|
||||
<thead class="text-dark text-nowrap">
|
||||
<tr>
|
||||
<td class="checkbox-column">
|
||||
<?php if ($status !== 'Closed') { ?>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" id="selectAllCheckbox" type="checkbox" onclick="checkAll(this)" onkeydown="checkAll(this)">
|
||||
<input class="form-check-input" id="selectAllCheckbox" type="checkbox" title="Select all on this page">
|
||||
</div>
|
||||
<?php } ?>
|
||||
</td>
|
||||
|
||||
<th>
|
||||
@@ -36,10 +172,11 @@
|
||||
Contact <?php if ($sort == 'contact_name') { echo $order_icon; } ?>
|
||||
</a>
|
||||
</th>
|
||||
<?php if ($config_module_enable_accounting && lookupUserPermission("module_sales") >= 2) { ?>
|
||||
|
||||
<?php if ($show_billing_column) { ?>
|
||||
<th class="text-center">
|
||||
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=ticket_billable&order=<?= $disp ?>">
|
||||
Billable <?php if ($sort == 'ticket_billable') { echo $order_icon; } ?>
|
||||
Billing <?php if ($sort == 'ticket_billable') { echo $order_icon; } ?>
|
||||
</a>
|
||||
</th>
|
||||
<?php } ?>
|
||||
@@ -54,6 +191,9 @@
|
||||
Status <?php if ($sort == 'ticket_status') { echo $order_icon; } ?>
|
||||
</a>
|
||||
</th>
|
||||
<?php if ($sla_filter_in_use) { ?>
|
||||
<th>SLA</th>
|
||||
<?php } ?>
|
||||
<th>
|
||||
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=user_name&order=<?= $disp ?>">
|
||||
Assigned <?php if ($sort == 'user_name') { echo $order_icon; } ?>
|
||||
@@ -74,51 +214,36 @@
|
||||
<tbody>
|
||||
<?php
|
||||
|
||||
while ($row = mysqli_fetch_assoc($sql)) {
|
||||
$ticket_id = intval($row['ticket_id']);
|
||||
foreach ($tickets as $ticket_id => $row) {
|
||||
$ticket_prefix = escapeHtml($row['ticket_prefix']);
|
||||
$ticket_number = intval($row['ticket_number']);
|
||||
$ticket_subject = escapeHtml($row['ticket_subject']);
|
||||
$ticket_priority = escapeHtml($row['ticket_priority']);
|
||||
$ticket_status_id = intval($row['ticket_status_id']);
|
||||
$ticket_status_name = escapeHtml($row['ticket_status_name']);
|
||||
$ticket_status_color = escapeHtml($row['ticket_status_color']);
|
||||
$ticket_billable = intval($row['ticket_billable']);
|
||||
$ticket_scheduled_for = escapeHtml($row['ticket_schedule']);
|
||||
$ticket_created_at = escapeHtml($row['ticket_created_at']);
|
||||
$ticket_created_at_time_ago = timeAgo($row['ticket_created_at']);
|
||||
$ticket_updated_at = escapeHtml($row['ticket_updated_at']);
|
||||
$ticket_updated_at_time_ago = timeAgo($row['ticket_updated_at']);
|
||||
$ticket_closed_at = escapeHtml($row['ticket_closed_at']);
|
||||
$ticket_resolved_at = escapeHtml($row['ticket_resolved_at']);
|
||||
$ticket_is_open = empty($ticket_resolved_at) && empty($ticket_closed_at);
|
||||
|
||||
// SLA alert stages are maintained by cron/ticket_sla.php (1 = warned, 2 = breached)
|
||||
$ticket_sla_alert_stage = max(intval($row['ticket_response_sla_alert_stage']), intval($row['ticket_resolution_sla_alert_stage']));
|
||||
$ticket_sla_paused = intval($row['ticket_status_pauses_sla']);
|
||||
// A paused ticket isn't running down its clock, so drop the
|
||||
// at-risk warning - a breach already recorded still stands
|
||||
if (intval($row['ticket_status_pauses_sla']) && $ticket_sla_alert_stage < 2) {
|
||||
if ($ticket_sla_paused && $ticket_sla_alert_stage < 2) {
|
||||
$ticket_sla_alert_stage = 0;
|
||||
}
|
||||
if (empty($ticket_updated_at)) {
|
||||
if (!empty($ticket_closed_at)) {
|
||||
$ticket_updated_at_display = "<p>Never</p>";
|
||||
} else {
|
||||
$ticket_updated_at_display = "<p class='text-danger'>Never</p>";
|
||||
}
|
||||
} else {
|
||||
$ticket_updated_at_display = "$ticket_updated_at_time_ago<br><small class='text-secondary'>$ticket_updated_at</small>";
|
||||
}
|
||||
|
||||
$project_id = intval($row['ticket_project_id']);
|
||||
|
||||
$client_id = intval($row['ticket_client_id']);
|
||||
$client_name = escapeHtml($row['client_name']);
|
||||
$contact_id = intval($row['contact_id']);
|
||||
$contact_name = escapeHtml($row['contact_name']);
|
||||
$contact_email = escapeHtml($row['contact_email']);
|
||||
if ($client_id) {
|
||||
$has_client = "&client_id=$client_id";
|
||||
} else {
|
||||
$has_client = "";
|
||||
}
|
||||
$has_client = $client_id ? "&client_id=$client_id" : "";
|
||||
|
||||
if ($ticket_priority == "Urgent") {
|
||||
$ticket_priority_color = "dark";
|
||||
@@ -132,76 +257,68 @@
|
||||
|
||||
$ticket_assigned_to = intval($row['ticket_assigned_to']);
|
||||
if (empty($ticket_assigned_to)) {
|
||||
if (!empty($ticket_closed_at)) {
|
||||
$ticket_assigned_to_display = "<p>Unassigned</p>";
|
||||
} else {
|
||||
$ticket_assigned_to_display = "<p class='text-muted'>Unassigned</p>";
|
||||
}
|
||||
$ticket_assigned_to_display = $ticket_is_open ? "<span class='text-muted'>Unassigned</span>" : "<span>Unassigned</span>";
|
||||
} else {
|
||||
$ticket_assigned_to_display = escapeHtml($row['user_name']);
|
||||
}
|
||||
|
||||
if (empty($contact_name)) {
|
||||
$contact_display = "-";
|
||||
$contact_display = "<span class='text-muted'>-</span>";
|
||||
} else {
|
||||
$contact_display = "<div><a href='contact.php?client_id=$client_id&contact_id=$contact_id'>$contact_name</a></div>";
|
||||
$contact_display = "<a href='contact.php?client_id=$client_id&contact_id=$contact_id'>$contact_name</a>";
|
||||
}
|
||||
|
||||
$ticket_invoice_id = intval($row['ticket_invoice_id']);
|
||||
$ticket_quote_id = intval($row['ticket_quote_id']);
|
||||
|
||||
// Get who last updated the ticket - to be shown in the last Response column
|
||||
|
||||
// Defaults to prevent undefined errors
|
||||
// Last reply, from the batched lookup above
|
||||
$ticket_reply_created_at = "";
|
||||
$ticket_reply_created_at_time_ago = "Never";
|
||||
$ticket_reply_by_display = "";
|
||||
$ticket_reply_type = "Client"; // Default to client for un-replied tickets
|
||||
$ticket_reply_type = "Client"; // Un-replied tickets are waiting on us
|
||||
|
||||
$sql_ticket_reply = mysqli_query($mysqli,
|
||||
"SELECT ticket_reply_type, ticket_reply_created_at, contact_name, user_name FROM ticket_replies
|
||||
LEFT JOIN users ON ticket_reply_by = user_id
|
||||
LEFT JOIN contacts ON ticket_reply_by = contact_id
|
||||
WHERE ticket_reply_ticket_id = $ticket_id
|
||||
AND ticket_reply_archived_at IS NULL
|
||||
ORDER BY ticket_reply_id DESC LIMIT 1"
|
||||
);
|
||||
$row = mysqli_fetch_assoc($sql_ticket_reply);
|
||||
|
||||
if ($row) {
|
||||
$ticket_reply_type = escapeHtml($row['ticket_reply_type']);
|
||||
if (isset($ticket_replies[$ticket_id])) {
|
||||
$reply = $ticket_replies[$ticket_id];
|
||||
$ticket_reply_type = escapeHtml($reply['ticket_reply_type']);
|
||||
if ($ticket_reply_type == "Client") {
|
||||
$ticket_reply_by_display = escapeHtml($row['contact_name']);
|
||||
$ticket_reply_by_display = escapeHtml($reply['contact_name']);
|
||||
} else {
|
||||
$ticket_reply_by_display = escapeHtml($row['user_name']);
|
||||
$ticket_reply_by_display = escapeHtml($reply['user_name']);
|
||||
}
|
||||
$ticket_reply_created_at = escapeHtml($row['ticket_reply_created_at']);
|
||||
$ticket_reply_created_at_time_ago = timeAgo($ticket_reply_created_at);
|
||||
$ticket_reply_created_at = escapeHtml($reply['ticket_reply_created_at']);
|
||||
$ticket_reply_created_at_time_ago = timeAgo($reply['ticket_reply_created_at']);
|
||||
}
|
||||
|
||||
// Task progress, from the batched lookup above
|
||||
$task_count = isset($ticket_tasks[$ticket_id]) ? intval($ticket_tasks[$ticket_id]['task_count']) : 0;
|
||||
$completed_task_count = isset($ticket_tasks[$ticket_id]) ? intval($ticket_tasks[$ticket_id]['completed_count']) : 0;
|
||||
$tasks_completed_percent = $task_count ? round(($completed_task_count / $task_count) * 100) : 0;
|
||||
|
||||
// Get Tasks
|
||||
// Get Tasks
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('task_id') AS count FROM tickets, tasks WHERE ticket_id = task_ticket_id AND ticket_project_id = $project_id"));
|
||||
$task_count = $row['count'];
|
||||
$sql_tasks = mysqli_query( $mysqli, "SELECT * FROM tasks WHERE task_ticket_id = $ticket_id ORDER BY task_created_at ASC");
|
||||
$task_count = mysqli_num_rows($sql_tasks);
|
||||
// Get Completed Task Count
|
||||
$sql_tasks_completed = mysqli_query($mysqli,
|
||||
"SELECT * FROM tasks
|
||||
WHERE task_ticket_id = $ticket_id
|
||||
AND task_completed_at IS NOT NULL"
|
||||
);
|
||||
$completed_task_count = mysqli_num_rows($sql_tasks_completed);
|
||||
|
||||
// Tasks Completed Percent
|
||||
if($task_count) {
|
||||
$tasks_completed_percent = round(($completed_task_count / $task_count) * 100);
|
||||
/*
|
||||
* Row highlighting, in priority order. The title explains why the
|
||||
* row is coloured - previously you just got a red row with no
|
||||
* indication of which of several rules had fired.
|
||||
*/
|
||||
$row_class = '';
|
||||
$row_title = '';
|
||||
if ($ticket_is_open && $ticket_sla_alert_stage == 2) {
|
||||
$row_class = 'table-danger';
|
||||
$row_title = 'SLA breached';
|
||||
} elseif ($ticket_is_open && $ticket_sla_alert_stage == 1) {
|
||||
$row_class = 'table-warning';
|
||||
$row_title = 'SLA at risk';
|
||||
} elseif ($ticket_is_open && $ticket_reply_type == "Client") {
|
||||
$row_class = 'table-warning';
|
||||
$row_title = 'Waiting on a reply from us';
|
||||
}
|
||||
if ($ticket_is_open && $ticket_reply_created_at_time_ago == "Never") {
|
||||
$row_class .= ' font-weight-bold';
|
||||
$row_title = $row_title ? $row_title . ', not yet replied to' : 'Not yet replied to';
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<tr class="<?php if(empty($ticket_closed_at) && empty($ticket_updated_at)) { echo "text-bold"; }?> <?php if (empty($ticket_closed_at) && $ticket_sla_alert_stage == 2) { echo "table-danger"; } elseif (empty($ticket_closed_at) && ($ticket_reply_type == "Client" || $ticket_sla_alert_stage == 1)) { echo "table-warning"; } ?>">
|
||||
<tr class="<?= trim($row_class) ?>" <?php if ($row_title) { ?>title="<?= $row_title ?>"<?php } ?>>
|
||||
|
||||
<td class="checkbox-column">
|
||||
<!-- Ticket Bulk Select (for open tickets) -->
|
||||
@@ -223,45 +340,43 @@
|
||||
<td>
|
||||
<a href="ticket.php?ticket_id=<?= "$ticket_id$has_client" ?>"><?= $ticket_subject ?></a>
|
||||
|
||||
<?php if($task_count && $completed_task_count > 0) { ?>
|
||||
<div class="progress mt-1" style="height: 15px;">
|
||||
<div class="progress-bar" style="width: <?= $tasks_completed_percent ?>%;"><?= $completed_task_count.' / '.$task_count ?></div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<?php if($task_count && $completed_task_count == 0) { ?>
|
||||
<div class="mt-1" style="height: 15px; background-color:#e9ecef;">
|
||||
<p class="text-center" ><?= $completed_task_count.' / '.$task_count ?></p>
|
||||
</div>
|
||||
<?php if ($task_count) { ?>
|
||||
<div class="progress mt-1" style="height: 15px; position: relative;" title="<?= $completed_task_count ?> of <?= $task_count ?> tasks complete">
|
||||
<div class="progress-bar bg-secondary" style="width: <?= $tasks_completed_percent ?>%;"></div>
|
||||
<small class="justify-content-center d-flex position-absolute w-100 text-dark"><?= $completed_task_count ?> / <?= $task_count ?> tasks</small>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</td>
|
||||
|
||||
<!-- Ticket Contact -->
|
||||
<!-- Ticket Client / Contact -->
|
||||
<td>
|
||||
<?php if (!$client_url) { ?>
|
||||
<a href="tickets.php?client_id=<?= $client_id ?>"><strong><?= $client_name ?></strong></a>
|
||||
<?php if ($client_id) { ?>
|
||||
<div><a href="tickets.php?client_id=<?= $client_id ?>"><strong><?= $client_name ?></strong></a></div>
|
||||
<?php } else { ?>
|
||||
<!-- Tickets with no client rendered an empty bold link to client_id=0 -->
|
||||
<div class="text-muted">No client</div>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
<div><?= $contact_display ?></div>
|
||||
</td>
|
||||
|
||||
<!-- Ticket Billable (if accounting enabled -->
|
||||
<?php if ($config_module_enable_accounting && lookupUserPermission("module_sales") >= 2) { ?>
|
||||
<!-- Ticket Billing (if accounting enabled) -->
|
||||
<?php if ($show_billing_column) { ?>
|
||||
<td class="text-center">
|
||||
<?php if ($ticket_invoice_id) { ?>
|
||||
<a href="invoice.php?client_id=<?= $client_id ?>&invoice_id=<?= $ticket_invoice_id ?>"><span class='badge badge-pill badge-success p-2'>Invoiced</span></a>
|
||||
<?php } else if ($ticket_quote_id) { ?>
|
||||
<a href="quote.php?client_id=<?= $client_id ?>"e_id=<?= $ticket_quote_id ?>"><span class='badge badge-pill badge-primary p-2'>Quoted</span></a>
|
||||
<a href="invoice.php?client_id=<?= $client_id ?>&invoice_id=<?= $ticket_invoice_id ?>"><span class="badge badge-pill badge-success p-2">Invoiced</span></a>
|
||||
<?php } elseif ($ticket_quote_id) { ?>
|
||||
<a href="quote.php?client_id=<?= $client_id ?>"e_id=<?= $ticket_quote_id ?>"><span class="badge badge-pill badge-primary p-2">Quoted</span></a>
|
||||
<?php } else { ?>
|
||||
<a href="#"
|
||||
class="ajax-modal"
|
||||
data-modal-url="modals/ticket/ticket_billable.php?id=<?= $ticket_id ?>">
|
||||
<?php
|
||||
if ($ticket_billable == 1) {
|
||||
echo "<span class='badge badge-pill badge-success p-2'><i class='fas fa-fw fa-check'></i></span>";
|
||||
} else {
|
||||
echo "<span class='badge badge-pill badge-secondary p-2'><i class='fas fa-fw fa-minus'></i></span>";
|
||||
}
|
||||
?>
|
||||
</a>
|
||||
<a href="#" class="ajax-modal" data-modal-url="modals/ticket/ticket_billable.php?id=<?= $ticket_id ?>"
|
||||
title="<?= $ticket_billable ? 'Billable - click to change' : 'Not billable - click to change' ?>">
|
||||
<?php if ($ticket_billable) { ?>
|
||||
<span class="badge badge-pill badge-success p-2"><i class="fas fa-fw fa-dollar-sign"></i></span>
|
||||
<?php } else { ?>
|
||||
<span class="badge badge-pill badge-secondary p-2"><i class="fas fa-fw fa-minus"></i></span>
|
||||
<?php } ?>
|
||||
</a>
|
||||
<?php } ?>
|
||||
</td>
|
||||
<?php } ?>
|
||||
@@ -271,21 +386,50 @@
|
||||
<a href="#"
|
||||
<?php if (lookupUserPermission("module_support") >= 2 && empty($ticket_closed_at)) { ?>
|
||||
class="ajax-modal"
|
||||
data-modal-url = "modals/ticket/ticket_priority.php?id=<?= $ticket_id ?>"
|
||||
data-modal-url="modals/ticket/ticket_priority.php?id=<?= $ticket_id ?>"
|
||||
<?php } ?>
|
||||
>
|
||||
<span class='p-2 badge badge-pill badge-<?= $ticket_priority_color ?>'>
|
||||
<?= $ticket_priority ?>
|
||||
</span>
|
||||
<span class="p-2 badge badge-pill badge-<?= $ticket_priority_color ?>"><?= $ticket_priority ?></span>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<!-- Ticket Status -->
|
||||
<td>
|
||||
<span class='badge badge-pill text-light p-2' style="background-color: <?= $ticket_status_color ?>"><?= $ticket_status_name ?></span>
|
||||
<?php if (isset ($ticket_scheduled_for)) { echo "<div class='mt-1'> <small class='text-secondary'> $ticket_scheduled_for </small></div>"; } ?>
|
||||
<span class="badge badge-pill text-light p-2" style="background-color: <?= $ticket_status_color ?>"><?= $ticket_status_name ?></span>
|
||||
<?php if (!empty($ticket_scheduled_for)) { ?>
|
||||
<div class="mt-1"><small class="text-secondary"><i class="fas fa-fw fa-calendar-check mr-1"></i><?= $ticket_scheduled_for ?></small></div>
|
||||
<?php } ?>
|
||||
</td>
|
||||
|
||||
<!-- Ticket SLA state (only when SLAs are in use) -->
|
||||
<?php if ($sla_filter_in_use) { ?>
|
||||
<td class="text-nowrap">
|
||||
<?php
|
||||
if (!intval($row['ticket_sla_id'])) {
|
||||
echo "<span class='text-muted'>-</span>";
|
||||
} elseif ($ticket_sla_paused) {
|
||||
echo "<span class='badge badge-pill badge-secondary p-2' title='The resolution clock is paused in this status'><i class='fas fa-fw fa-pause'></i> Paused</span>";
|
||||
} elseif ($ticket_sla_alert_stage == 2) {
|
||||
echo "<span class='badge badge-pill badge-danger p-2'><i class='fas fa-fw fa-stopwatch'></i> Breached</span>";
|
||||
} elseif ($ticket_sla_alert_stage == 1) {
|
||||
echo "<span class='badge badge-pill badge-warning p-2'><i class='fas fa-fw fa-stopwatch'></i> At risk</span>";
|
||||
} elseif (!$ticket_is_open) {
|
||||
$sla_met = intval($row['ticket_response_sla_met'] ?? 0) && (is_null($row['ticket_resolution_due_at']) || intval($row['ticket_resolution_sla_met'] ?? 0));
|
||||
echo $sla_met ? "<span class='badge badge-pill badge-success p-2'>Met</span>" : "<span class='text-muted'>-</span>";
|
||||
} else {
|
||||
// Whichever clock is still running is the one worth showing
|
||||
$sla_due_at = empty($row['ticket_first_response_at']) ? $row['ticket_response_due_at'] : $row['ticket_resolution_due_at'];
|
||||
$sla_due_label = empty($row['ticket_first_response_at']) ? 'First response due' : 'Resolution due';
|
||||
if (!empty($sla_due_at)) {
|
||||
echo "<small class='text-secondary' title='" . escapeHtml($sla_due_label) . " " . escapeHtml($sla_due_at) . "'>" . timeAgo($sla_due_at) . "</small>";
|
||||
} else {
|
||||
echo "<span class='text-muted'>-</span>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<?php } ?>
|
||||
|
||||
<!-- Ticket Assigned agent -->
|
||||
<td>
|
||||
<a href="#"
|
||||
@@ -300,10 +444,10 @@
|
||||
|
||||
<!-- Ticket Last Response -->
|
||||
<td>
|
||||
<div title="<?= $ticket_reply_created_at ?>">
|
||||
<?= $ticket_reply_created_at_time_ago ?>
|
||||
</div>
|
||||
<div class="text-secondary"><?= $ticket_reply_by_display ?></div>
|
||||
<div title="<?= $ticket_reply_created_at ?>"><?= $ticket_reply_created_at_time_ago ?></div>
|
||||
<?php if ($ticket_reply_by_display) { ?>
|
||||
<small class="text-secondary"><?= $ticket_reply_by_display ?><?php if ($ticket_reply_type == "Client") { echo " <i class='fas fa-fw fa-reply text-warning' title='Client replied last'></i>"; } ?></small>
|
||||
<?php } ?>
|
||||
</td>
|
||||
|
||||
<!-- Ticket Created At -->
|
||||
@@ -325,5 +469,8 @@
|
||||
</div>
|
||||
</form>
|
||||
<?php require_once "../includes/filter_footer.php"; ?>
|
||||
</div>
|
||||
|
||||
<?php } ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Tickets - agent listing
|
||||
*
|
||||
* Owns filter parsing, the filter bar and the ticket query. Rendering is
|
||||
* handed to ticket_list.php or ticket_kanban.php, both of which read the
|
||||
* variables set here.
|
||||
*/
|
||||
|
||||
// Default Column Sortby Filter
|
||||
$sort = "ticket_number";
|
||||
$order = "DESC";
|
||||
@@ -18,61 +26,173 @@ if (isset($_GET['client_id'])) {
|
||||
// Perms
|
||||
enforceUserPermission('module_support');
|
||||
|
||||
// Ticket status from GET
|
||||
if (isset($_GET['status']) && is_array($_GET['status']) && !empty($_GET['status'])) {
|
||||
// Sanitize each element of the status array
|
||||
$sanitizedStatuses = array();
|
||||
foreach ($_GET['status'] as $status) {
|
||||
// Escape each status to prevent SQL injection
|
||||
$sanitizedStatuses[] = "'" . intval($status) . "'";
|
||||
/*
|
||||
* Normalise legacy query strings into the canonical filter params before
|
||||
* anything reads them, so every link and form on the page speaks one
|
||||
* language and $_GET can be trusted as the single source of truth.
|
||||
*
|
||||
* status=Open|Closed -> state=open|closed (status[] stays as-is)
|
||||
* billable=1&unbilled -> billing=unbilled
|
||||
*
|
||||
* The old page carried the coarse Open/Closed state in a scalar `status`
|
||||
* while the status multi-select posted `status[]`. Both landed in the same
|
||||
* key, so whichever came last in the query string won and the two controls
|
||||
* silently fought each other. They are separate params now.
|
||||
*/
|
||||
if (isset($_GET['status']) && !is_array($_GET['status'])) {
|
||||
if (!isset($_GET['state'])) {
|
||||
$_GET['state'] = strtolower($_GET['status']) === 'closed' ? 'closed' : 'open';
|
||||
}
|
||||
unset($_GET['status']);
|
||||
}
|
||||
if (isset($_GET['billable']) && $_GET['billable'] == '1' && isset($_GET['unbilled'])) {
|
||||
$_GET['billing'] = 'unbilled';
|
||||
}
|
||||
unset($_GET['billable'], $_GET['unbilled']);
|
||||
|
||||
// Convert the sanitized statuses into a comma-separated string
|
||||
$sanitizedStatusesString = implode(",", $sanitizedStatuses);
|
||||
$ticket_status_snippet = "ticket_status IN ($sanitizedStatusesString)";
|
||||
// filter_header.php built this from the raw query string - rebuild it so the
|
||||
// sort links carry the normalised params too
|
||||
$get_copy = $_GET;
|
||||
unset($get_copy['sort'], $get_copy['order']);
|
||||
$url_query_strings_sort = http_build_query($get_copy);
|
||||
|
||||
} else {
|
||||
|
||||
// TODO: Convert this to use the status IDs
|
||||
if (isset($_GET['status']) && ($_GET['status']) == 'Closed') {
|
||||
$status = 'Closed';
|
||||
$ticket_status_snippet = "ticket_resolved_at IS NOT NULL";
|
||||
} else {
|
||||
// Default - Show open tickets
|
||||
$status = 'Open';
|
||||
$ticket_status_snippet = "ticket_resolved_at IS NULL";
|
||||
}
|
||||
/*
|
||||
* filter_header.php strips a sort value down to [a-z_], which blocks injection
|
||||
* but happily leaves an invalid column name behind - ?sort=ticket_id;DROP...
|
||||
* becomes ticket_idtickets, and the ORDER BY then takes the page down with an
|
||||
* uncaught mysqli exception. Only these columns are sortable here, so anything
|
||||
* else falls back to the default.
|
||||
*/
|
||||
$sortable_columns = array(
|
||||
'ticket_number', 'ticket_subject', 'client_name', 'contact_name',
|
||||
'ticket_billable', 'ticket_priority', 'ticket_status', 'user_name',
|
||||
'ticket_updated_at', 'ticket_created_at'
|
||||
);
|
||||
if (!in_array($sort, $sortable_columns, true)) {
|
||||
$sort = 'ticket_number';
|
||||
}
|
||||
|
||||
if (isset($_GET['billable']) && ($_GET['billable']) == '1') {
|
||||
if (isset($_GET['unbilled'])) {
|
||||
$billable = 1;
|
||||
$ticket_billable_snippet = "AND ticket_billable = 1 AND ticket_invoice_id = 0";
|
||||
$ticket_status_snippet = '1 = 1';
|
||||
/*
|
||||
* Builds a link to this page with filter overrides applied. Pass null to drop
|
||||
* a filter. Paging always resets - a page number from the previous filter set
|
||||
* means nothing once the result set changes.
|
||||
*/
|
||||
function ticketsFilterUrl($overrides = [])
|
||||
{
|
||||
$params = $_GET;
|
||||
foreach ($overrides as $name => $value) {
|
||||
if ($value === null) {
|
||||
unset($params[$name]);
|
||||
} else {
|
||||
$params[$name] = $value;
|
||||
}
|
||||
}
|
||||
unset($params['page']);
|
||||
|
||||
return escapeHtml('?' . http_build_query($params));
|
||||
}
|
||||
|
||||
// View - list or kanban
|
||||
$view = ($_GET['view'] ?? '') === 'kanban' ? 'kanban' : (($_GET['view'] ?? '') === 'list' ? 'list' : '');
|
||||
if (!$view) {
|
||||
// Admin default (0 = list, 2 = kanban)
|
||||
$view = $config_ticket_default_view === 2 ? 'kanban' : 'list';
|
||||
}
|
||||
|
||||
// Chips describing what is currently filtering the list, built as each
|
||||
// filter is parsed. Each one renders with an x that drops just that filter.
|
||||
$active_filters = array();
|
||||
|
||||
// Specific ticket statuses - takes precedence over the open/closed state
|
||||
$status_filter = array();
|
||||
if (isset($_GET['status']) && is_array($_GET['status']) && !empty($_GET['status'])) {
|
||||
$status_filter = array_filter(array_map('intval', $_GET['status']));
|
||||
}
|
||||
|
||||
/*
|
||||
* Coarse state. Anything with a resolved date is "closed" as far as this page
|
||||
* is concerned, matching what the counts in the header report.
|
||||
*/
|
||||
$state = $_GET['state'] ?? '';
|
||||
if (!in_array($state, array('open', 'closed', 'all'), true)) {
|
||||
$state = 'open';
|
||||
}
|
||||
|
||||
// Billing filter
|
||||
$billing_filter = $_GET['billing'] ?? '';
|
||||
if (!in_array($billing_filter, array('unbilled', 'invoiced', 'nonbillable'), true)) {
|
||||
$billing_filter = '';
|
||||
}
|
||||
$ticket_billable_snippet = '';
|
||||
if ($billing_filter == 'unbilled') {
|
||||
$ticket_billable_snippet = 'AND ticket_billable = 1 AND ticket_invoice_id = 0';
|
||||
$active_filters[] = array('label' => 'Billing', 'value' => 'Billable, not invoiced', 'drop' => 'billing');
|
||||
} elseif ($billing_filter == 'invoiced') {
|
||||
$ticket_billable_snippet = 'AND ticket_invoice_id > 0';
|
||||
$active_filters[] = array('label' => 'Billing', 'value' => 'Invoiced', 'drop' => 'billing');
|
||||
} elseif ($billing_filter == 'nonbillable') {
|
||||
$ticket_billable_snippet = 'AND ticket_billable = 0';
|
||||
$active_filters[] = array('label' => 'Billing', 'value' => 'Not billable', 'drop' => 'billing');
|
||||
}
|
||||
|
||||
/*
|
||||
* Unbilled work is nearly always on tickets that are already done, so a
|
||||
* billing filter with no state asked for widens to every ticket rather than
|
||||
* returning the empty list the open default would give. The old page did the
|
||||
* same thing invisibly - now it shows up as a chip.
|
||||
*/
|
||||
if ($billing_filter && !isset($_GET['state'])) {
|
||||
$state = 'all';
|
||||
}
|
||||
|
||||
// Status snippet - specific statuses win, otherwise the coarse state
|
||||
if ($status_filter) {
|
||||
$status_ids = implode(',', $status_filter);
|
||||
$ticket_status_snippet = "ticket_status IN ($status_ids)";
|
||||
|
||||
$status_names = array();
|
||||
$sql_status_names = mysqli_query($mysqli, "SELECT ticket_status_name FROM ticket_statuses WHERE ticket_status_id IN ($status_ids) ORDER BY ticket_status_order");
|
||||
while ($row = mysqli_fetch_assoc($sql_status_names)) {
|
||||
$status_names[] = escapeHtml($row['ticket_status_name']);
|
||||
}
|
||||
$active_filters[] = array('label' => 'Status', 'value' => implode(', ', $status_names), 'drop' => 'status');
|
||||
} elseif ($state == 'closed') {
|
||||
$ticket_status_snippet = 'ticket_resolved_at IS NOT NULL';
|
||||
} elseif ($state == 'all') {
|
||||
$ticket_status_snippet = '1 = 1';
|
||||
} else {
|
||||
$billable = 0;
|
||||
$ticket_billable_snippet = '';
|
||||
$ticket_status_snippet = 'ticket_resolved_at IS NULL';
|
||||
}
|
||||
|
||||
// Category Filter
|
||||
if (isset($_GET['category']) & !empty($_GET['category'])) {
|
||||
$category_query = 'AND (ticket_category = ' . intval($_GET['category']) . ')';
|
||||
$category_query = '';
|
||||
$category_filter = '';
|
||||
if (!empty($_GET['category'])) {
|
||||
$category_filter = intval($_GET['category']);
|
||||
} else {
|
||||
// Default - any
|
||||
$category_query = '';
|
||||
$category_filter = '';
|
||||
$category_query = "AND ticket_category = $category_filter";
|
||||
$active_filters[] = array('label' => 'Category', 'value' => getFieldById('categories', $category_filter, 'category_name', 'html'), 'drop' => 'category');
|
||||
}
|
||||
|
||||
// Ticket assignment status filter
|
||||
// Default - any
|
||||
$ticket_assigned_query = '';
|
||||
$ticket_assigned_filter_id = '';
|
||||
// SLA state filter - breached / at risk / met / no SLA
|
||||
// Priority Filter
|
||||
$ticket_priority_query = '';
|
||||
$priority_filter = '';
|
||||
if (!empty($_GET['priority']) && in_array($_GET['priority'], array('Low', 'Medium', 'High', 'Urgent'), true)) {
|
||||
$priority_filter = $_GET['priority'];
|
||||
$ticket_priority_query = "AND ticket_priority = '$priority_filter'";
|
||||
$active_filters[] = array('label' => 'Priority', 'value' => $priority_filter, 'drop' => 'priority');
|
||||
}
|
||||
|
||||
// SLA state filter - breached / at risk / paused / met / no SLA
|
||||
$ticket_sla_query = '';
|
||||
$ticket_sla_filter = '';
|
||||
if (isset($_GET['sla']) && !empty($_GET['sla'])) {
|
||||
$sla_filter_labels = array(
|
||||
'breached' => 'SLA breached',
|
||||
'at_risk' => 'SLA at risk',
|
||||
'paused' => 'SLA paused',
|
||||
'met' => 'SLA met',
|
||||
'none' => 'No SLA'
|
||||
);
|
||||
if (!empty($_GET['sla']) && isset($sla_filter_labels[$_GET['sla']])) {
|
||||
$ticket_sla_filter = $_GET['sla'];
|
||||
if ($ticket_sla_filter == 'breached') {
|
||||
$ticket_sla_query = 'AND ticket_sla_id > 0 AND (ticket_response_sla_alert_stage = 2 OR ticket_resolution_sla_alert_stage = 2 OR ticket_response_sla_met = 0 OR ticket_resolution_sla_met = 0)';
|
||||
@@ -85,25 +205,46 @@ if (isset($_GET['sla']) && !empty($_GET['sla'])) {
|
||||
} elseif ($ticket_sla_filter == 'none') {
|
||||
$ticket_sla_query = 'AND ticket_sla_id = 0';
|
||||
}
|
||||
$active_filters[] = array('label' => 'SLA', 'value' => $sla_filter_labels[$ticket_sla_filter], 'drop' => 'sla');
|
||||
}
|
||||
|
||||
if (isset($_GET['assigned']) & !empty($_GET['assigned'])) {
|
||||
// Ticket assignment filter
|
||||
$ticket_assigned_query = '';
|
||||
$ticket_assigned_filter_id = '';
|
||||
if (!empty($_GET['assigned'])) {
|
||||
if ($_GET['assigned'] == 'unassigned') {
|
||||
$ticket_assigned_query = 'AND ticket_assigned_to = 0';
|
||||
$ticket_assigned_filter_id = 0;
|
||||
$active_filters[] = array('label' => 'Assigned', 'value' => 'Unassigned', 'drop' => 'assigned');
|
||||
} else {
|
||||
$ticket_assigned_query = 'AND ticket_assigned_to = ' . intval($_GET['assigned']);
|
||||
$ticket_assigned_filter_id = intval($_GET['assigned']);
|
||||
$ticket_assigned_query = "AND ticket_assigned_to = $ticket_assigned_filter_id";
|
||||
$assigned_name = $ticket_assigned_filter_id === intval($session_user_id) ? 'Me' : getFieldById('users', $ticket_assigned_filter_id, 'user_name', 'html');
|
||||
$active_filters[] = array('label' => 'Assigned', 'value' => $assigned_name, 'drop' => 'assigned');
|
||||
}
|
||||
}
|
||||
|
||||
// Project filter
|
||||
// Default - any (including tickets without a project)
|
||||
$ticket_project_snippet = '';
|
||||
$ticket_project_filter_id = '';
|
||||
if (isset($_GET['project']) & !empty($_GET['project']) && $_GET['project'] > '0') {
|
||||
$ticket_project_snippet = 'AND ticket_project_id = ' . intval($_GET['project']);
|
||||
if (!empty($_GET['project'])) {
|
||||
$ticket_project_filter_id = intval($_GET['project']);
|
||||
$ticket_project_snippet = "AND ticket_project_id = $ticket_project_filter_id";
|
||||
$active_filters[] = array('label' => 'Project', 'value' => getFieldById('projects', $ticket_project_filter_id, 'project_name', 'html'), 'drop' => 'project');
|
||||
}
|
||||
|
||||
// Date range - filter_header.php resolves the canned ranges into $dtf / $dtt
|
||||
$date_filter_active = isset($_GET['canned_date']) && $_GET['canned_date'] !== 'custom' && $_GET['canned_date'] !== 'alltime';
|
||||
if (!$date_filter_active && !empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') {
|
||||
$date_filter_active = true;
|
||||
}
|
||||
if ($date_filter_active) {
|
||||
$active_filters[] = array('label' => 'Created', 'value' => "$dtf to $dtt", 'drop' => array('canned_date', 'dtf', 'dtt'));
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($q !== '') {
|
||||
$active_filters[] = array('label' => 'Search', 'value' => stripslashes(escapeHtml($q)), 'drop' => 'q');
|
||||
}
|
||||
|
||||
// Ticket client access overide - This is the only way to show tickets without a client to agents with restricted client access
|
||||
@@ -112,358 +253,313 @@ if ($client_access_string) {
|
||||
$access_permission_query_overide = "AND ticket_client_id IN (0,$client_access_string)";
|
||||
}
|
||||
|
||||
// Main ticket query:
|
||||
$query =
|
||||
"SELECT SQL_CALC_FOUND_ROWS * FROM tickets
|
||||
LEFT JOIN clients ON ticket_client_id = client_id
|
||||
/*
|
||||
* Columns the two views need. Explicit rather than SELECT * - the tickets
|
||||
* table carries ticket_details (longtext), and pulling a page of ticket
|
||||
* bodies to render a list that never shows them is pure overhead.
|
||||
*/
|
||||
$ticket_select_columns =
|
||||
"ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_priority,
|
||||
ticket_status, ticket_billable, ticket_schedule, ticket_order,
|
||||
ticket_created_at, ticket_updated_at, ticket_resolved_at, ticket_closed_at,
|
||||
ticket_client_id, ticket_contact_id, ticket_assigned_to, ticket_project_id,
|
||||
ticket_invoice_id, ticket_quote_id, ticket_sla_id,
|
||||
ticket_first_response_at, ticket_response_due_at, ticket_resolution_due_at,
|
||||
ticket_response_sla_met, ticket_resolution_sla_met,
|
||||
ticket_response_sla_alert_stage, ticket_resolution_sla_alert_stage,
|
||||
ticket_status_id, ticket_status_name, ticket_status_color, ticket_status_pauses_sla,
|
||||
client_name, contact_id, contact_name, contact_email,
|
||||
user_name, asset_name, category_name";
|
||||
|
||||
$ticket_joins =
|
||||
"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
|
||||
LEFT JOIN assets ON ticket_asset_id = asset_id
|
||||
LEFT JOIN locations ON ticket_location_id = location_id
|
||||
LEFT JOIN vendors ON ticket_vendor_id = vendor_id
|
||||
LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id
|
||||
LEFT JOIN categories ON ticket_category = category_id
|
||||
WHERE $ticket_status_snippet " . $ticket_assigned_query . "
|
||||
LEFT JOIN categories ON ticket_category = category_id";
|
||||
|
||||
$ticket_where =
|
||||
"WHERE $ticket_status_snippet
|
||||
$ticket_assigned_query
|
||||
$category_query
|
||||
AND DATE(ticket_created_at) BETWEEN '$dtf' AND '$dtt'
|
||||
AND (CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR client_name LIKE '%$q%' OR ticket_subject LIKE '%$q%' OR ticket_status_name LIKE '%$q%' OR ticket_priority LIKE '%$q%' OR user_name LIKE '%$q%' OR contact_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR ticket_vendor_ticket_number LIKE '%q%')
|
||||
$ticket_priority_query
|
||||
$ticket_sla_query
|
||||
$ticket_billable_snippet
|
||||
$ticket_project_snippet
|
||||
$access_permission_query_overide
|
||||
$client_query
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN '$sort' = 'ticket_priority' THEN
|
||||
CASE ticket_priority
|
||||
WHEN 'Urgent' THEN 0
|
||||
WHEN 'High' THEN 1
|
||||
WHEN 'Medium' THEN 2
|
||||
WHEN 'Low' THEN 3
|
||||
ELSE 4 -- Optional: for unexpected priority values
|
||||
END
|
||||
ELSE NULL
|
||||
END $order,
|
||||
$sort $order -- Apply normal sorting by $sort and $order
|
||||
LIMIT $record_from, $record_to";
|
||||
AND DATE(ticket_created_at) BETWEEN '$dtf' AND '$dtt'
|
||||
AND (CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR client_name LIKE '%$q%' OR ticket_subject LIKE '%$q%' OR ticket_status_name LIKE '%$q%' OR ticket_priority LIKE '%$q%' OR user_name LIKE '%$q%' OR contact_name LIKE '%$q%' OR asset_name LIKE '%$q%' OR vendor_name LIKE '%$q%' OR ticket_vendor_ticket_number LIKE '%$q%')";
|
||||
|
||||
$sql = mysqli_query($mysqli,$query);
|
||||
// Counts for the quick views in the header - scope-wide, not filter-aware
|
||||
$count_where = "$access_permission_query_overide $client_query";
|
||||
$total_tickets_open = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_resolved_at IS NULL $count_where"))[0]);
|
||||
$total_tickets_closed = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_resolved_at IS NOT NULL $count_where"))[0]);
|
||||
$total_tickets_unassigned = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_assigned_to = 0 AND ticket_resolved_at IS NULL $count_where"))[0]);
|
||||
$user_active_assigned_tickets = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(ticket_id) FROM tickets WHERE ticket_assigned_to = $session_user_id AND ticket_resolved_at IS NULL $count_where"))[0]);
|
||||
|
||||
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
|
||||
// Only offer the SLA filter once SLAs are actually in use
|
||||
$sla_filter_in_use = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(sla_id) FROM slas WHERE sla_archived_at IS NULL"))[0] > 0;
|
||||
|
||||
//Get Total tickets open
|
||||
$sql_total_tickets_open = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_open FROM tickets WHERE ticket_resolved_at IS NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_open);
|
||||
$total_tickets_open = intval($row['total_tickets_open']);
|
||||
// Gates both the Billing column in the table and the Billing filter select
|
||||
$show_billing_column = $config_module_enable_accounting && lookupUserPermission("module_sales") >= 2;
|
||||
|
||||
//Get Total tickets closed
|
||||
$sql_total_tickets_closed = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_closed FROM tickets WHERE ticket_resolved_at IS NOT NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_closed);
|
||||
$total_tickets_closed = intval($row['total_tickets_closed']);
|
||||
|
||||
//Get Unassigned tickets
|
||||
$sql_total_tickets_unassigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_unassigned FROM tickets WHERE ticket_assigned_to = '0' AND ticket_resolved_at IS NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_unassigned);
|
||||
$total_tickets_unassigned = intval($row['total_tickets_unassigned']);
|
||||
|
||||
//Get Total tickets assigned to me
|
||||
$sql_total_tickets_assigned = mysqli_query($mysqli, "SELECT COUNT(ticket_id) AS total_tickets_assigned FROM tickets WHERE ticket_assigned_to = $session_user_id AND ticket_resolved_at IS NULL $client_query $access_permission_query_overide");
|
||||
$row = mysqli_fetch_assoc($sql_total_tickets_assigned);
|
||||
$user_active_assigned_tickets = intval($row['total_tickets_assigned']);
|
||||
|
||||
$sql_categories_filter = mysqli_query(
|
||||
$mysqli,
|
||||
"SELECT * FROM categories
|
||||
WHERE category_type = 'Ticket'
|
||||
AND category_archived_at IS NULL
|
||||
ORDER BY category_name"
|
||||
);
|
||||
/*
|
||||
* How many of the collapsed filters are set. The panel stays closed even when
|
||||
* filters are active - the count on the filter button and the chips below the
|
||||
* bar are what make them visible, without costing the vertical space an
|
||||
* auto-expanded panel would.
|
||||
*/
|
||||
$hidden_filter_count = 0;
|
||||
foreach (array('status', 'assigned', 'priority', 'category', 'sla', 'project', 'billing') as $panel_filter) {
|
||||
if (!empty($_GET[$panel_filter])) {
|
||||
$hidden_filter_count++;
|
||||
}
|
||||
}
|
||||
if ($date_filter_active) {
|
||||
$hidden_filter_count++;
|
||||
}
|
||||
|
||||
?>
|
||||
<style>
|
||||
.popover {
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
<div class="card card-dark">
|
||||
<div class="card-header py-2">
|
||||
<h3 class="card-title mt-2"><i class="fa fa-fw fa-life-ring mr-2"></i>Tickets
|
||||
<small class="ml-3">
|
||||
<a href="?<?= $client_url ?>status=Open" class="badge badge-pill text-light p-1 <?php if($status == 'Open') { echo "badge-light text-dark"; } ?>"><strong><?= $total_tickets_open ?></strong> Open</a> |
|
||||
<a href="?<?= $client_url ?>status=Closed" class="badge badge-pill text-light p-1 <?php if($status == 'Closed') { echo "badge-light text-dark"; } ?>"><strong><?= $total_tickets_closed ?></strong> Closed</a>
|
||||
</small>
|
||||
</h3>
|
||||
<?php if (lookupUserPermission("module_support") >= 2) { ?>
|
||||
<div class="card-tools">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/ticket/ticket_add.php?<?= $client_url ?>" data-modal-size="lg">
|
||||
<i class="fas fa-plus"></i><span class="d-none d-lg-inline ml-2">New Ticket</span>
|
||||
</button>
|
||||
<?php if ($num_rows[0] > 0) { ?>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown"></button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item text-dark ajax-modal" href="#"
|
||||
data-modal-url="<?= buildExportModalUrl('modals/ticket/ticket_export.php', ['client_id', 'status', 'billable', 'unbilled', 'category', 'assigned', 'sla', 'project', 'q'], ['dtf' => $dtf, 'dtt' => $dtt]) ?>">
|
||||
<i class="fa fa-fw fa-download mr-2"></i>Export
|
||||
</a>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="card card-dark">
|
||||
<div class="card-header py-2">
|
||||
<h3 class="card-title mt-2"><i class="fa fa-fw fa-life-ring mr-2"></i>Tickets
|
||||
<small class="ml-3">
|
||||
<a href="<?= ticketsFilterUrl(['state' => 'open', 'status' => null]) ?>" class="badge badge-pill p-1 <?= (!$status_filter && $state == 'open') ? 'badge-light text-dark' : 'text-light' ?>"><strong><?= $total_tickets_open ?></strong> Open</a> |
|
||||
<a href="<?= ticketsFilterUrl(['state' => 'closed', 'status' => null]) ?>" class="badge badge-pill p-1 <?= (!$status_filter && $state == 'closed') ? 'badge-light text-dark' : 'text-light' ?>"><strong><?= $total_tickets_closed ?></strong> Closed</a> |
|
||||
<a href="<?= ticketsFilterUrl(['state' => 'all', 'status' => null]) ?>" class="badge badge-pill p-1 <?= (!$status_filter && $state == 'all') ? 'badge-light text-dark' : 'text-light' ?>">All</a>
|
||||
</small>
|
||||
</h3>
|
||||
<?php if (lookupUserPermission("module_support") >= 2) { ?>
|
||||
<div class="card-tools">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/ticket/ticket_add.php?<?= $client_url ?>" data-modal-size="lg">
|
||||
<i class="fas fa-plus"></i><span class="d-none d-lg-inline ml-2">New Ticket</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown"></button>
|
||||
<div class="dropdown-menu dropdown-menu-right">
|
||||
<a class="dropdown-item text-dark ajax-modal" href="#"
|
||||
data-modal-url="<?= buildExportModalUrl('modals/ticket/ticket_export.php', ['client_id', 'state', 'status', 'billing', 'category', 'priority', 'assigned', 'sla', 'project', 'q'], ['dtf' => $dtf, 'dtt' => $dtt]) ?>">
|
||||
<i class="fa fa-fw fa-download mr-2"></i>Export
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form autocomplete="off">
|
||||
<?php if ($client_url) { ?>
|
||||
<input type="hidden" name="client_id" value="<?= $client_id ?>">
|
||||
<?php } ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form autocomplete="off">
|
||||
<?php if ($client_url) { ?>
|
||||
<input type="hidden" name="client_id" value="<?= $client_id ?>">
|
||||
<?php } ?>
|
||||
<input type="hidden" name="status" value="<?= $status ?>">
|
||||
<input type="hidden" name="view" value="<?= escapeHtml($_GET['view'] ?? 'list') ?>">
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<div class="input-group mb-3 mb-sm-0">
|
||||
<input type="hidden" name="view" value="<?= $view ?>">
|
||||
<input type="hidden" name="state" value="<?= $state ?>">
|
||||
<input type="hidden" name="sort" value="<?= escapeHtml($sort) ?>">
|
||||
<input type="hidden" name="order" value="<?= escapeHtml($order) ?>">
|
||||
<?php if ($billing_filter && !$show_billing_column) { ?>
|
||||
<!-- No Billing select for this agent, but a drill-through filter must still survive a search submit -->
|
||||
<input type="hidden" name="billing" value="<?= escapeHtml($billing_filter) ?>">
|
||||
<?php } ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-group mb-2 mb-sm-0">
|
||||
<div class="input-group">
|
||||
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search Tickets">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" data-toggle="collapse" data-target="#advancedFilter"><i class="fas fa-filter"></i></button>
|
||||
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
|
||||
<button class="btn <?= $hidden_filter_count ? 'btn-warning' : 'btn-secondary' ?>" type="button" data-toggle="collapse" data-target="#advancedFilter" title="Filters">
|
||||
<i class="fas fa-filter"></i><?php if ($hidden_filter_count) { ?><span class="ml-1"><?= $hidden_filter_count ?></span><?php } ?>
|
||||
</button>
|
||||
<button class="btn btn-primary" type="submit"><i class="fa fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Only offer the SLA filter once SLAs are actually in use
|
||||
$sla_filter_in_use = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(sla_id) FROM slas WHERE sla_archived_at IS NULL"))[0] > 0;
|
||||
if ($sla_filter_in_use) { ?>
|
||||
<div class="col-sm-3">
|
||||
<div class="form-group">
|
||||
<select class="form-control select2" name="sla" onchange="this.form.submit()">
|
||||
<option value="">- All SLA States -</option>
|
||||
<option <?php if ($ticket_sla_filter == 'breached') { echo "selected"; } ?> value="breached">SLA breached</option>
|
||||
<option <?php if ($ticket_sla_filter == 'at_risk') { echo "selected"; } ?> value="at_risk">SLA at risk</option>
|
||||
<option <?php if ($ticket_sla_filter == 'paused') { echo "selected"; } ?> value="paused">SLA paused</option>
|
||||
<option <?php if ($ticket_sla_filter == 'met') { echo "selected"; } ?> value="met">SLA met</option>
|
||||
<option <?php if ($ticket_sla_filter == 'none') { echo "selected"; } ?> value="none">No SLA</option>
|
||||
<div class="col-sm-8">
|
||||
<div class="btn-group float-sm-right">
|
||||
<a href="<?= $ticket_assigned_filter_id === intval($session_user_id) ? ticketsFilterUrl(['assigned' => null]) : ticketsFilterUrl(['assigned' => $session_user_id]) ?>"
|
||||
class="btn <?= $ticket_assigned_filter_id === intval($session_user_id) ? 'btn-primary' : 'btn-outline-primary' ?>">
|
||||
<i class="fas fa-fw fa-user"></i><span class="d-none d-xl-inline ml-2">Mine</span> | <strong><?= $user_active_assigned_tickets ?></strong>
|
||||
</a>
|
||||
<a href="<?= $ticket_assigned_filter_id === 0 ? ticketsFilterUrl(['assigned' => null]) : ticketsFilterUrl(['assigned' => 'unassigned']) ?>"
|
||||
class="btn <?= $ticket_assigned_filter_id === 0 ? 'btn-danger' : 'btn-outline-danger' ?>">
|
||||
<i class="fas fa-fw fa-exclamation-triangle"></i><span class="d-none d-xl-inline ml-2">Unassigned</span> | <strong><?= $total_tickets_unassigned ?></strong>
|
||||
</a>
|
||||
<a href="<?= ticketsFilterUrl(['view' => $view == 'kanban' ? 'list' : 'kanban']) ?>" class="btn btn-outline-dark ml-2" title="Switch to the <?= $view == 'kanban' ? 'list' : 'kanban' ?> view">
|
||||
<i class="fa fa-fw <?= $view == 'kanban' ? 'fa-list' : 'fa-columns' ?>"></i>
|
||||
<span class="d-none d-xl-inline ml-2"><?= $view == 'kanban' ? 'List' : 'Kanban' ?></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapse mt-3 <?php if ($hidden_filter_count) { echo "show"; } ?>" id="advancedFilter">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>Status</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="status[]" data-placeholder="Any status" multiple>
|
||||
<?php
|
||||
$sql_ticket_status = mysqli_query($mysqli, "SELECT ticket_status_id, ticket_status_name FROM ticket_statuses WHERE ticket_status_active = 1 ORDER BY ticket_status_order");
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_status)) {
|
||||
$filter_status_id = intval($row['ticket_status_id']);
|
||||
$filter_status_name = escapeHtml($row['ticket_status_name']);
|
||||
?>
|
||||
<option value="<?= $filter_status_id ?>" <?php if (in_array($filter_status_id, $status_filter, true)) { echo 'selected'; } ?>><?= $filter_status_name ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>Assigned to</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="assigned">
|
||||
<option value="">Anyone</option>
|
||||
<option value="unassigned" <?php if ($ticket_assigned_filter_id === 0) { echo "selected"; } ?>>Unassigned</option>
|
||||
|
||||
<?php
|
||||
$sql_assign_to = mysqli_query($mysqli, "SELECT user_id, user_name FROM users WHERE user_type = 1 AND user_archived_at IS NULL ORDER BY user_name ASC");
|
||||
while ($row = mysqli_fetch_assoc($sql_assign_to)) {
|
||||
$filter_user_id = intval($row['user_id']);
|
||||
$filter_user_name = escapeHtml($row['user_name']);
|
||||
?>
|
||||
<option value="<?= $filter_user_id ?>" <?php if ($ticket_assigned_filter_id === $filter_user_id) { echo "selected"; } ?>><?= $filter_user_name ?><?php if ($filter_user_id === intval($session_user_id)) { echo ' (me)'; } ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>Priority</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="priority">
|
||||
<option value="">Any priority</option>
|
||||
<?php foreach (array('Urgent', 'High', 'Medium', 'Low') as $priority_option) { ?>
|
||||
<option value="<?= $priority_option ?>" <?php if ($priority_filter == $priority_option) { echo "selected"; } ?>><?= $priority_option ?></option>
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>Category</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="category">
|
||||
<option value="">Any category</option>
|
||||
|
||||
<?php
|
||||
$sql_categories_filter = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories WHERE category_type = 'Ticket' AND category_archived_at IS NULL ORDER BY category_name");
|
||||
while ($row = mysqli_fetch_assoc($sql_categories_filter)) {
|
||||
$filter_category_id = intval($row['category_id']);
|
||||
$filter_category_name = escapeHtml($row['category_name']);
|
||||
?>
|
||||
<option value="<?= $filter_category_id ?>" <?php if ($category_filter === $filter_category_id) { echo "selected"; } ?>><?= $filter_category_name ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>Project</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="project">
|
||||
<option value="">Any project</option>
|
||||
|
||||
<?php
|
||||
$sql_projects = mysqli_query($mysqli, "SELECT project_id, project_prefix, project_number, project_name FROM projects WHERE project_completed_at IS NULL AND project_archived_at IS NULL ORDER BY project_name ASC");
|
||||
while ($row = mysqli_fetch_assoc($sql_projects)) {
|
||||
$filter_project_id = intval($row['project_id']);
|
||||
$filter_project_label = escapeHtml($row['project_prefix'] . intval($row['project_number']) . ' - ' . $row['project_name']);
|
||||
?>
|
||||
<option value="<?= $filter_project_id ?>" <?php if ($ticket_project_filter_id === $filter_project_id) { echo "selected"; } ?>><?= $filter_project_label ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($show_billing_column) { ?>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>Billing</label>
|
||||
<select class="form-control select2" name="billing" onchange="this.form.submit()">
|
||||
<option value="">Any</option>
|
||||
<option value="unbilled" <?php if ($billing_filter == 'unbilled') { echo "selected"; } ?>>Billable, not invoiced</option>
|
||||
<option value="invoiced" <?php if ($billing_filter == 'invoiced') { echo "selected"; } ?>>Invoiced</option>
|
||||
<option value="nonbillable" <?php if ($billing_filter == 'nonbillable') { echo "selected"; } ?>>Not billable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="col-sm-3">
|
||||
<div class="form-group">
|
||||
<select class="form-control select2" name="category" onchange="this.form.submit()">
|
||||
<option value="">- All Categories -</option>
|
||||
|
||||
<?php
|
||||
while ($row = mysqli_fetch_assoc($sql_categories_filter)) {
|
||||
$category_id = intval($row['category_id']);
|
||||
$category_name = escapeHtml($row['category_name']);
|
||||
?>
|
||||
<option <?php if ($category_filter == $category_id) { echo "selected"; } ?> value="<?= $category_id ?>"><?= $category_name ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<div class="btn-group float-right">
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-dark dropdown-toggle" id="dropdownMenuButton" data-toggle="dropdown">
|
||||
<i class="fa fa-fw fa-eye"></i>
|
||||
<span class="d-none d-xl-inline ml-2">View</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="<?=htmlspecialchars('?' . http_build_query(array_merge($_GET, ['view' => 'list']))); ?>">List</a>
|
||||
<?php if ($status !== 'Closed') {?>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item " href="<?=htmlspecialchars('?' . http_build_query(array_merge($_GET, ['view' => 'kanban']))); ?>">Kanban</a>
|
||||
<?php if ($sla_filter_in_use) { ?>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>SLA</label>
|
||||
<select class="form-control select2" name="sla" onchange="this.form.submit()">
|
||||
<option value="">Any SLA state</option>
|
||||
<?php foreach ($sla_filter_labels as $sla_option => $sla_option_label) { ?>
|
||||
<option value="<?= $sla_option ?>" <?php if ($ticket_sla_filter == $sla_option) { echo "selected"; } ?>><?= $sla_option_label ?></option>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-dark dropdown-toggle" id="categoriesDropdownMenuButton" data-toggle="dropdown">
|
||||
<i class="fa fa-fw fa-envelope"></i>
|
||||
<span class="d-none d-xl-inline ml-2">My Tickets</span>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item" href="?<?= $client_url ?>status=Open&assigned=<?= $session_user_id ?>">Active tickets (<?= $user_active_assigned_tickets ?>)</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item " href="?<?= $client_url ?>status=Closed&assigned=<?= $session_user_id ?>">Closed tickets</a>
|
||||
</div>
|
||||
</div>
|
||||
<a href="?<?= $client_url ?>assigned=unassigned" class="btn btn-outline-danger">
|
||||
<i class="fa fa-fw fa-exclamation-triangle"></i>
|
||||
<span class="d-none d-xl-inline ml-2">Unassigned</span> | <strong> <?= $total_tickets_unassigned ?></strong>
|
||||
</a>
|
||||
|
||||
<?php if (lookupUserPermission("module_support") >= 2) { ?>
|
||||
<div class="dropdown ml-2" id="bulkActionButton" hidden>
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<i class="fas fa-fw fa-layer-group mr-2"></i>Bulk Action (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/ticket/ticket_bulk_assign.php"
|
||||
data-bulk="true">
|
||||
<i class="fas fa-fw fa-user-check mr-2"></i>Assign Agent
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/ticket/ticket_bulk_edit_category.php"
|
||||
data-bulk="true">
|
||||
<i class="fas fa-fw fa-layer-group mr-2"></i>Set Category
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/ticket/ticket_bulk_edit_priority.php"
|
||||
data-bulk="true">
|
||||
<i class="fas fa-fw fa-thermometer-half mr-2"></i>Set Priority
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/ticket/ticket_bulk_reply.php"
|
||||
data-modal-size="lg"
|
||||
data-bulk="true">
|
||||
<i class="fas fa-fw fa-paper-plane mr-2"></i>Update/Reply
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/ticket/ticket_bulk_add_project.php"
|
||||
data-bulk="true">
|
||||
<i class="fas fa-fw fa-project-diagram mr-2"></i>Set Project
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/ticket/ticket_bulk_merge.php"
|
||||
data-bulk="true">
|
||||
<i class="fas fa-fw fa-clone mr-2"></i>Merge
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item ajax-modal" href="#"
|
||||
data-modal-url="modals/ticket/ticket_bulk_resolve.php"
|
||||
data-modal-size="lg"
|
||||
data-bulk="true">
|
||||
<i class="fas fa-fw fa-check mr-2"></i>Resolve
|
||||
</a>
|
||||
<?php if (lookupUserPermission("module_support") === 3) { ?>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger text-bold confirm-link" type="submit" form="bulkActions" name="bulk_delete_tickets">
|
||||
<i class="fas fa-fw fa-trash mr-2"></i>Delete
|
||||
</button>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="collapse mt-3
|
||||
<?php
|
||||
if (isset($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01'
|
||||
|| (isset($_GET['status']) && is_array($_GET['status'])
|
||||
|| (isset($_GET['assigned']) && $_GET['assigned']
|
||||
)))
|
||||
{ echo "show"; }
|
||||
?>"
|
||||
id="advancedFilter"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Date range</label>
|
||||
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
|
||||
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date']) ?? '' ?>">
|
||||
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
|
||||
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Ticket Status</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="status[]" data-placeholder="Select Status" multiple>
|
||||
|
||||
<?php $sql_ticket_status = mysqli_query($mysqli, "SELECT * FROM ticket_statuses WHERE ticket_status_active = 1 ORDER BY ticket_status_order");
|
||||
while ($row = mysqli_fetch_assoc($sql_ticket_status)) {
|
||||
$ticket_status_id = intval($row['ticket_status_id']);
|
||||
$ticket_status_name = escapeHtml($row['ticket_status_name']); ?>
|
||||
|
||||
<option value="<?= $ticket_status_id ?>" <?php if (isset($_GET['status']) && is_array($_GET['status']) && in_array($ticket_status_id, $_GET['status'])) { echo 'selected'; } ?>> <?= $ticket_status_name ?> </option>
|
||||
|
||||
<?php } ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Assigned to</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="assigned">
|
||||
<option value="" <?php if ($ticket_assigned_filter_id == "") { echo "selected"; } ?>>Any</option>
|
||||
<option value="unassigned" <?php if ($ticket_assigned_filter_id == "0") { echo "selected"; } ?>>Unassigned</option>
|
||||
<?php } ?>
|
||||
|
||||
<?php
|
||||
$sql_assign_to = mysqli_query($mysqli, "SELECT * FROM users WHERE user_type = 1 AND user_archived_at IS NULL ORDER BY user_name ASC");
|
||||
while ($row = mysqli_fetch_assoc($sql_assign_to)) {
|
||||
$user_id = intval($row['user_id']);
|
||||
$user_name = escapeHtml($row['user_name']);
|
||||
?>
|
||||
<option <?php if ($ticket_assigned_filter_id == $user_id) { echo "selected"; } ?> value="<?= $user_id ?>"><?= $user_name ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group">
|
||||
<label>Project</label>
|
||||
<select onchange="this.form.submit()" class="form-control select2" name="project">
|
||||
<option value="" <?php if ($ticket_project_filter_id == "") { echo "selected"; } ?>>Any</option>
|
||||
|
||||
<?php
|
||||
$sql_projects = mysqli_query($mysqli, "SELECT * FROM projects WHERE project_completed_at IS NULL and project_archived_at IS NULL ORDER BY project_name ASC");
|
||||
while ($row = mysqli_fetch_assoc($sql_projects)) {
|
||||
$project_id = intval($row['project_id']);
|
||||
$project_prefix = escapeHtml($row['project_prefix']);
|
||||
$project_number = intval($row['project_number']);
|
||||
$project_name = escapeHtml($row['project_name']);
|
||||
?>
|
||||
<option <?php if ($ticket_project_filter_id == $project_id) { echo "selected"; } ?> value="<?= $project_id ?>"><?= $project_prefix . $project_number . " - " . $project_name ?></option>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-md-0">
|
||||
<label>Created</label>
|
||||
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
|
||||
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date'] ?? '') ?>">
|
||||
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
|
||||
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if ($active_filters) { ?>
|
||||
<!-- The filters are collapsed by default, so this line is what stops an
|
||||
active filter from being invisible -->
|
||||
<div class="mt-2">
|
||||
<?php foreach ($active_filters as $filter) {
|
||||
$drop = is_array($filter['drop']) ? array_fill_keys($filter['drop'], null) : array($filter['drop'] => null);
|
||||
?>
|
||||
<span class="badge badge-pill badge-light border p-2 mr-1 font-weight-normal">
|
||||
<span class="text-secondary"><?= $filter['label'] ?>:</span> <?= $filter['value'] ?>
|
||||
<a href="<?= ticketsFilterUrl($drop) ?>" class="text-danger ml-1" title="Remove this filter"><i class="fas fa-times"></i></a>
|
||||
</span>
|
||||
<?php } ?>
|
||||
<a href="<?= escapeHtml('?' . http_build_query(array_filter(array('client_id' => $_GET['client_id'] ?? null, 'view' => $view)))) ?>" class="small text-danger ml-1">Clear all</a>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
|
||||
if (isset($_GET["view"])) {
|
||||
if ($_GET["view"] == "list") {
|
||||
require_once "ticket_list.php";
|
||||
} elseif ($_GET["view"] == "kanban") {
|
||||
require_once "ticket_kanban.php";
|
||||
}
|
||||
if ($view == 'kanban') {
|
||||
require_once "ticket_kanban.php";
|
||||
} else {
|
||||
// here we have to get default view setting
|
||||
if ($config_ticket_default_view === 0) {
|
||||
require_once "ticket_list.php";
|
||||
} elseif ($config_ticket_default_view === 2) {
|
||||
require_once "ticket_kanban.php";
|
||||
} else {
|
||||
require_once "ticket_list.php";
|
||||
}
|
||||
require_once "ticket_list.php";
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -114,22 +114,21 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
|
||||
|
||||
// ClipboardJS
|
||||
|
||||
// Tooltip
|
||||
// Tooltip - manual trigger only, so a copy flash can never stay stranded
|
||||
// on screen. This page is standalone and does not load js/app.js, so it
|
||||
// carries its own copy of the helper.
|
||||
function flashTooltip(button, message) {
|
||||
$(button)
|
||||
.tooltip('dispose')
|
||||
.tooltip({
|
||||
trigger: 'manual',
|
||||
placement: 'bottom',
|
||||
title: message
|
||||
})
|
||||
.tooltip('show');
|
||||
|
||||
$('button').tooltip({
|
||||
trigger: 'click',
|
||||
placement: 'bottom'
|
||||
});
|
||||
|
||||
function setTooltip(btn, message) {
|
||||
$(btn).tooltip('hide')
|
||||
.attr('data-original-title', message)
|
||||
.tooltip('show');
|
||||
}
|
||||
|
||||
function hideTooltip(btn) {
|
||||
setTimeout(function() {
|
||||
$(btn).tooltip('hide');
|
||||
$(button).tooltip('dispose');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@@ -138,13 +137,11 @@ $data = "otpauth://totp/ITFlow:$session_email?secret=$token";
|
||||
var clipboard = new ClipboardJS('.clipboardjs');
|
||||
|
||||
clipboard.on('success', function(e) {
|
||||
setTooltip(e.trigger, 'Copied!');
|
||||
hideTooltip(e.trigger);
|
||||
flashTooltip(e.trigger, 'Copied!');
|
||||
});
|
||||
|
||||
clipboard.on('error', function(e) {
|
||||
setTooltip(e.trigger, 'Failed!');
|
||||
hideTooltip(e.trigger);
|
||||
flashTooltip(e.trigger, 'Failed!');
|
||||
});
|
||||
|
||||
// Enable Popovers
|
||||
|
||||
50
js/app.js
50
js/app.js
@@ -371,35 +371,15 @@ $(document).ready(function() {
|
||||
// ClipboardJS fix for Bootstrap modals
|
||||
$.fn.modal.Constructor.prototype._enforceFocus = function() {};
|
||||
|
||||
// Tooltip
|
||||
$('button').tooltip({
|
||||
trigger: 'click',
|
||||
placement: 'bottom'
|
||||
});
|
||||
|
||||
function setTooltip(btn, message) {
|
||||
$(btn).tooltip('hide')
|
||||
.attr('data-original-title', message)
|
||||
.tooltip('show');
|
||||
}
|
||||
|
||||
function hideTooltip(btn) {
|
||||
setTimeout(function() {
|
||||
$(btn).tooltip('hide');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
var clipboard = new ClipboardJS('.clipboardjs');
|
||||
|
||||
clipboard.on('success', function(e) {
|
||||
setTooltip(e.trigger, 'Copied!');
|
||||
hideTooltip(e.trigger);
|
||||
flashTooltip(e.trigger, 'Copied!');
|
||||
});
|
||||
|
||||
clipboard.on('error', function(e) {
|
||||
setTooltip(e.trigger, 'Failed!');
|
||||
hideTooltip(e.trigger);
|
||||
flashTooltip(e.trigger, 'Failed!');
|
||||
});
|
||||
|
||||
// Enable Popovers
|
||||
@@ -502,3 +482,29 @@ $(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-s
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Bootstrap tooltips are only used for the brief "Copied!" flash on a copy
|
||||
* button. Everything else relies on the browser's own title attribute.
|
||||
*
|
||||
* This used to be `$('button').tooltip({trigger: 'click'})`, which attached a
|
||||
* click-toggled tooltip to EVERY button in the app. A click-triggered tooltip
|
||||
* is only dismissed by clicking the same button again - clicking anywhere else
|
||||
* leaves it on screen - and if the button was then removed from the DOM by a
|
||||
* modal closing or an ajax refresh, its tooltip was orphaned and stayed up
|
||||
* until the next page load.
|
||||
*/
|
||||
function flashTooltip(button, message) {
|
||||
$(button)
|
||||
.tooltip('dispose')
|
||||
.tooltip({
|
||||
trigger: 'manual',
|
||||
placement: 'bottom',
|
||||
title: message
|
||||
})
|
||||
.tooltip('show');
|
||||
|
||||
setTimeout(function() {
|
||||
$(button).tooltip('dispose');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user