UX and architecture fixes: bulk-delete, template guard, statuses config
Bug fixes: - bulk-delete action called undefined bulkDelete() — wired to the existing showBulkDeleteModal() so the confirmation modal actually shows UX: - Template loader now checks for existing title/description and asks for confirmation before overwriting user-typed content - Visibility select shows a dynamic hint paragraph that updates when the user changes the selection (public/internal/confidential) Architecture: - TICKET_STATUSES added to config as single source of truth; all hardcoded ['Open','Pending','In Progress','Closed'] arrays in DashboardView now read from config; bulk-status modal in dashboard.js reads window.TICKET_STATUSES (set from PHP) with array fallback - ASSET_VERSION now auto-computed from max mtime of dashboard/ticket CSS+JS files so browsers always pick up changes on deploy; manual override still available via ASSET_VERSION in .env - Removed 10 dead standalone stat methods from StatsModel (getOpenTicketCount, getClosedTicketCount, getTicketsByPriority, etc.) — all superseded by the consolidated fetchAllStats() queries, never called externally Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -130,7 +130,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (typeof showBulkPriorityModal === 'function') showBulkPriorityModal();
|
if (typeof showBulkPriorityModal === 'function') showBulkPriorityModal();
|
||||||
break;
|
break;
|
||||||
case 'bulk-delete':
|
case 'bulk-delete':
|
||||||
if (typeof bulkDelete === 'function') bulkDelete();
|
showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
case 'clear-selection':
|
case 'clear-selection':
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -760,10 +760,7 @@ function showBulkStatusModal() {
|
|||||||
<label for="bulkStatus">New Status:</label>
|
<label for="bulkStatus">New Status:</label>
|
||||||
<select id="bulkStatus" class="lt-select">
|
<select id="bulkStatus" class="lt-select">
|
||||||
<option value="">Select Status...</option>
|
<option value="">Select Status...</option>
|
||||||
<option value="Open">Open</option>
|
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
||||||
<option value="Pending">Pending</option>
|
|
||||||
<option value="In Progress">In Progress</option>
|
|
||||||
<option value="Closed">Closed</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
|
|||||||
+17
-2
@@ -25,8 +25,23 @@ $GLOBALS['config'] = [
|
|||||||
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
|
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
|
||||||
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
|
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
|
||||||
|
|
||||||
// Asset cache-busting version string (bump when CSS/JS changes)
|
// Asset cache-busting version — auto-computed from dashboard.js/css mtime so
|
||||||
'ASSET_VERSION' => $envVars['ASSET_VERSION'] ?? '20260329',
|
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
||||||
|
'ASSET_VERSION' => (function() use ($envVars) {
|
||||||
|
if (!empty($envVars['ASSET_VERSION'])) return $envVars['ASSET_VERSION'];
|
||||||
|
$files = [
|
||||||
|
__DIR__ . '/../assets/css/dashboard.css',
|
||||||
|
__DIR__ . '/../assets/css/ticket.css',
|
||||||
|
__DIR__ . '/../assets/js/dashboard.js',
|
||||||
|
__DIR__ . '/../assets/js/ticket.js',
|
||||||
|
];
|
||||||
|
$mtime = 0;
|
||||||
|
foreach ($files as $f) { if (file_exists($f)) $mtime = max($mtime, filemtime($f)); }
|
||||||
|
return $mtime ?: '20260329';
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// Canonical ticket statuses — single source of truth used by views and JS
|
||||||
|
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
|
||||||
|
|
||||||
// Database settings
|
// Database settings
|
||||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||||
|
|||||||
@@ -22,110 +22,6 @@ class StatsModel {
|
|||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of open tickets
|
|
||||||
*/
|
|
||||||
public function getOpenTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of closed tickets
|
|
||||||
*/
|
|
||||||
public function getClosedTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tickets grouped by priority
|
|
||||||
*/
|
|
||||||
public function getTicketsByPriority(): array {
|
|
||||||
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$data = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$data['P' . $row['priority']] = (int)$row['count'];
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tickets grouped by status
|
|
||||||
*/
|
|
||||||
public function getTicketsByStatus(): array {
|
|
||||||
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$data = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$data[$row['status']] = (int)$row['count'];
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tickets grouped by category
|
|
||||||
*/
|
|
||||||
public function getTicketsByCategory(): array {
|
|
||||||
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$data = [];
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
|
||||||
$data[$row['category']] = (int)$row['count'];
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get average resolution time in hours
|
|
||||||
*/
|
|
||||||
public function getAverageResolutionTime(): float {
|
|
||||||
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, closed_at)) as avg_hours
|
|
||||||
FROM tickets
|
|
||||||
WHERE status = 'Closed'
|
|
||||||
AND created_at IS NOT NULL
|
|
||||||
AND closed_at IS NOT NULL
|
|
||||||
AND closed_at > created_at";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of tickets created today
|
|
||||||
*/
|
|
||||||
public function getTicketsCreatedToday(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of tickets created this week
|
|
||||||
*/
|
|
||||||
public function getTicketsCreatedThisWeek(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of tickets closed today
|
|
||||||
*/
|
|
||||||
public function getTicketsClosedToday(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(closed_at) = CURDATE()";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tickets by assignee (top 5)
|
* Get tickets by assignee (top 5)
|
||||||
*/
|
*/
|
||||||
@@ -153,26 +49,6 @@ class StatsModel {
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get unassigned ticket count
|
|
||||||
*/
|
|
||||||
public function getUnassignedTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get critical (P1) ticket count
|
|
||||||
*/
|
|
||||||
public function getCriticalTicketCount(): int {
|
|
||||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$row = $result->fetch_assoc();
|
|
||||||
return (int)$row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all stats as a single array, respecting ticket visibility for the given user.
|
* Get all stats as a single array, respecting ticket visibility for the given user.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<option value="internal">Internal — Specific groups only</option>
|
<option value="internal">Internal — Specific groups only</option>
|
||||||
<option value="confidential">Confidential — Creator, assignee, and admins only</option>
|
<option value="confidential">Confidential — Creator, assignee, and admins only</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p id="visibilityHint" class="lt-form-hint">Everyone who is logged in can view this ticket.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite">
|
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite">
|
||||||
@@ -296,21 +297,39 @@ include __DIR__ . '/layout_header.php';
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Visibility groups toggle ──────────────────────────────
|
// ── Visibility groups toggle ──────────────────────────────
|
||||||
|
var visibilityHints = {
|
||||||
|
'public': 'Everyone who is logged in can view this ticket.',
|
||||||
|
'internal': 'Only members of the selected groups (plus admins) can view this ticket.',
|
||||||
|
'confidential': 'Only you, the assignee, and admins can view this ticket.'
|
||||||
|
};
|
||||||
function toggleVisibilityGroups() {
|
function toggleVisibilityGroups() {
|
||||||
var vis = document.getElementById('visibility').value;
|
var vis = document.getElementById('visibility').value;
|
||||||
var container = document.getElementById('visibilityGroupsContainer');
|
var container = document.getElementById('visibilityGroupsContainer');
|
||||||
|
var hint = document.getElementById('visibilityHint');
|
||||||
if (vis === 'internal') {
|
if (vis === 'internal') {
|
||||||
container.classList.remove('is-hidden');
|
container.classList.remove('is-hidden');
|
||||||
} else {
|
} else {
|
||||||
container.classList.add('is-hidden');
|
container.classList.add('is-hidden');
|
||||||
container.querySelectorAll('.visibility-group-checkbox').forEach(function (cb) { cb.checked = false; });
|
container.querySelectorAll('.visibility-group-checkbox').forEach(function (cb) { cb.checked = false; });
|
||||||
}
|
}
|
||||||
|
if (hint) hint.textContent = visibilityHints[vis] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Template loader ───────────────────────────────────────
|
// ── Template loader ───────────────────────────────────────
|
||||||
function loadTemplate() {
|
function loadTemplate() {
|
||||||
var tplId = document.getElementById('templateSelect').value;
|
var tplId = document.getElementById('templateSelect').value;
|
||||||
if (!tplId) return;
|
if (!tplId) return;
|
||||||
|
|
||||||
|
// Warn before overwriting content the user has already typed
|
||||||
|
var existingTitle = (document.getElementById('title').value || '').trim();
|
||||||
|
var existingDesc = (document.getElementById('description').value || '').trim();
|
||||||
|
if (existingTitle || existingDesc) {
|
||||||
|
if (!confirm('Applying this template will overwrite your current title and description. Continue?')) {
|
||||||
|
document.getElementById('templateSelect').value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data.success || !data.template) {
|
if (!data.success || !data.template) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ if (!empty($_GET['assigned_to'])) {
|
|||||||
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
$activeFilters[] = ['type' => 'assigned_to', 'value' => $_GET['assigned_to'], 'label' => 'Assigned: ' . $label];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$_lt_statuses = $GLOBALS['config']['TICKET_STATUSES'];
|
||||||
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
|
$currentStatus = isset($_GET['status']) ? explode(',', $_GET['status']) : ['Open', 'Pending', 'In Progress'];
|
||||||
$currentCategories = isset($_GET['category']) ? explode(',', $_GET['category']) : [];
|
$currentCategories = isset($_GET['category']) ? explode(',', $_GET['category']) : [];
|
||||||
$currentTypes = isset($_GET['type']) ? explode(',', $_GET['type']) : [];
|
$currentTypes = isset($_GET['type']) ? explode(',', $_GET['type']) : [];
|
||||||
@@ -181,7 +182,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<fieldset class="lt-filter-group">
|
<fieldset class="lt-filter-group">
|
||||||
<legend class="lt-filter-label">Status</legend>
|
<legend class="lt-filter-label">Status</legend>
|
||||||
<?php foreach (['Open', 'Pending', 'In Progress', 'Closed'] as $s): ?>
|
<?php foreach ($GLOBALS['config']['TICKET_STATUSES'] as $s): ?>
|
||||||
<label class="lt-filter-option">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
<input type="checkbox" class="lt-checkbox sidebar-filter"
|
||||||
name="status" value="<?= htmlspecialchars($s) ?>"
|
name="status" value="<?= htmlspecialchars($s) ?>"
|
||||||
@@ -592,11 +593,11 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<div class="lt-kv-row">
|
<div class="lt-kv-row">
|
||||||
<span class="lt-kv-label">Default status filters</span>
|
<span class="lt-kv-label">Default status filters</span>
|
||||||
<span class="lt-kv-value lt-flex lt-flex-wrap lt-flex-gap-sm">
|
<span class="lt-kv-value lt-flex lt-flex-wrap lt-flex-gap-sm">
|
||||||
<?php foreach (['Open','Pending','In Progress','Closed'] as $sf): ?>
|
<?php foreach ($_lt_statuses as $sf): ?>
|
||||||
<label class="lt-filter-option">
|
<label class="lt-filter-option">
|
||||||
<input type="checkbox" class="lt-checkbox" name="defaultFilters" value="<?= $sf ?>"
|
<input type="checkbox" class="lt-checkbox" name="defaultFilters" value="<?= htmlspecialchars($sf) ?>"
|
||||||
<?= in_array($sf, ['Open','Pending','In Progress']) ? 'checked' : '' ?>>
|
<?= in_array($sf, ['Open','Pending','In Progress']) ? 'checked' : '' ?>>
|
||||||
<?= $sf ?>
|
<?= htmlspecialchars($sf) ?>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</span>
|
</span>
|
||||||
@@ -824,6 +825,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
DASHBOARD INLINE SCRIPT
|
DASHBOARD INLINE SCRIPT
|
||||||
═══════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════ -->
|
||||||
<script nonce="<?= $nonce ?>">
|
<script nonce="<?= $nonce ?>">
|
||||||
|
window.TICKET_STATUSES = <?= json_encode($GLOBALS['config']['TICKET_STATUSES']) ?>;
|
||||||
// Initialize keyboard and table navigation
|
// Initialize keyboard and table navigation
|
||||||
if (window.lt) {
|
if (window.lt) {
|
||||||
lt.keys.initDefaults();
|
lt.keys.initDefaults();
|
||||||
|
|||||||
Reference in New Issue
Block a user