feat: duplicate detection + mark-as-duplicate, lt-toggle preferences in settings
- Dependencies tab: auto-loads potential duplicates via /api/check_duplicates.php on first activation; shows 'Mark duplicate' button per result which POSTs to ticket_dependencies with type=duplicates and refreshes the dependencies list - Settings modal: replaced checkboxes with lt-toggle switches for notifications_enabled and sound_effects; loads current user prefs on modal open and saves via /api/user_preferences.php on SAVE button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -507,6 +507,7 @@ function showTab(tabName) {
|
||||
initializeUploadZone();
|
||||
} else if (tabName === 'dependencies') {
|
||||
loadDependencies();
|
||||
loadPotentialDuplicates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,6 +532,70 @@ function loadDependencies() {
|
||||
});
|
||||
}
|
||||
|
||||
// Load potential duplicates from check_duplicates API and show "Mark as duplicate" buttons
|
||||
let _dupsLoaded = false;
|
||||
function loadPotentialDuplicates() {
|
||||
if (_dupsLoaded) return;
|
||||
_dupsLoaded = true;
|
||||
|
||||
const frame = document.getElementById('potentialDupsFrame');
|
||||
const list = document.getElementById('potentialDupsList');
|
||||
if (!frame || !list) return;
|
||||
|
||||
const title = window.ticketData?.title || document.querySelector('.title-input')?.textContent?.trim() || '';
|
||||
if (!title) return;
|
||||
|
||||
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
||||
.then(data => {
|
||||
if (!data.success || !data.duplicates || !data.duplicates.length) return;
|
||||
|
||||
// Filter out this ticket itself
|
||||
const thisId = String(window.ticketData.id);
|
||||
const dupes = data.duplicates.filter(d => String(d.ticket_id) !== thisId);
|
||||
if (!dupes.length) return;
|
||||
|
||||
let html = '<ul class="duplicate-list lt-text-sm">';
|
||||
dupes.forEach(dup => {
|
||||
html += `<li class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="padding:0.3rem 0">
|
||||
<a href="/ticket/${lt.escHtml(String(dup.ticket_id))}" class="lt-text-cyan lt-text-xs" target="_blank">#${lt.escHtml(String(dup.ticket_id))}</a>
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${lt.escHtml(dup.title)}</span>
|
||||
<span class="lt-text-muted lt-text-xs">${lt.escHtml(String(dup.similarity))}% · ${lt.escHtml(dup.status)}</span>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-xs mark-dup-btn"
|
||||
data-dup-id="${lt.escHtml(String(dup.ticket_id))}"
|
||||
title="Link this ticket as a duplicate of #${lt.escHtml(String(dup.ticket_id))}">
|
||||
Mark duplicate
|
||||
</button>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
list.innerHTML = html;
|
||||
frame.style.display = '';
|
||||
|
||||
list.querySelectorAll('.mark-dup-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const dupId = btn.dataset.dupId;
|
||||
const ticketId = window.ticketData.id;
|
||||
lt.api.post('/api/ticket_dependencies.php', {
|
||||
ticket_id: ticketId,
|
||||
depends_on_id: dupId,
|
||||
dependency_type: 'duplicates'
|
||||
}).then(res => {
|
||||
if (res.success) {
|
||||
btn.textContent = '✓ Linked';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('lt-btn-primary');
|
||||
lt.toast.success('Linked as duplicate of #' + dupId);
|
||||
loadDependencies();
|
||||
} else {
|
||||
lt.toast.error(res.error || 'Failed to link dependency');
|
||||
}
|
||||
}).catch(() => lt.toast.error('Network error'));
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {}); // silent — duplicate check is advisory only
|
||||
}
|
||||
|
||||
function showDependencyError(message) {
|
||||
const dependenciesList = document.getElementById('dependenciesList');
|
||||
const dependentsList = document.getElementById('dependentsList');
|
||||
|
||||
@@ -702,6 +702,13 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Potential Duplicates (loaded on first tab activation) -->
|
||||
<div class="lt-frame lt-mb-md" id="potentialDupsFrame" style="display:none">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header lt-text-amber">Potential Duplicates</div>
|
||||
<div class="lt-section-body" id="potentialDupsList" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="lt-frame lt-mb-md">
|
||||
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
|
||||
<div class="lt-section-header">Current Dependencies</div>
|
||||
@@ -833,6 +840,31 @@ $progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4 class="lt-subsection-header">Preferences</h4>
|
||||
<div class="lt-kv-grid">
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label" id="settingNotifLabel">Notifications</span>
|
||||
<span class="lt-kv-value">
|
||||
<label class="lt-toggle" aria-labelledby="settingNotifLabel">
|
||||
<input type="checkbox" id="settingNotificationsEnabled" checked>
|
||||
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
|
||||
<span class="lt-toggle-label lt-text-xs">Receive in-app notifications</span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="lt-kv-row">
|
||||
<span class="lt-kv-label" id="settingSoundLabel">Sound effects</span>
|
||||
<span class="lt-kv-value">
|
||||
<label class="lt-toggle" aria-labelledby="settingSoundLabel">
|
||||
<input type="checkbox" id="settingSoundEffects">
|
||||
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
|
||||
<span class="lt-toggle-label lt-text-xs">UI sound feedback</span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4 class="lt-subsection-header">Keyboard Shortcuts</h4>
|
||||
<div class="shortcuts-list lt-text-xs">
|
||||
@@ -1028,9 +1060,37 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
|
||||
// Settings save/cancel
|
||||
// Load user preference toggles on settings modal open
|
||||
(function() {
|
||||
fetch('/api/user_preferences.php', { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.success || !d.preferences) return;
|
||||
var notifEl = document.getElementById('settingNotificationsEnabled');
|
||||
var soundEl = document.getElementById('settingSoundEffects');
|
||||
if (notifEl && d.preferences.notifications_enabled !== undefined)
|
||||
notifEl.checked = d.preferences.notifications_enabled == '1' || d.preferences.notifications_enabled === true;
|
||||
if (soundEl && d.preferences.sound_effects !== undefined)
|
||||
soundEl.checked = d.preferences.sound_effects == '1' || d.preferences.sound_effects === true;
|
||||
}).catch(function() {});
|
||||
})();
|
||||
|
||||
var saveSettingsBtn = document.getElementById('saveSettingsBtn');
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.addEventListener('click', function () {
|
||||
// Save lt-toggle preferences
|
||||
var notifEl = document.getElementById('settingNotificationsEnabled');
|
||||
var soundEl = document.getElementById('settingSoundEffects');
|
||||
var prefsToSave = {};
|
||||
if (notifEl) prefsToSave.notifications_enabled = notifEl.checked ? '1' : '0';
|
||||
if (soundEl) prefsToSave.sound_effects = soundEl.checked ? '1' : '0';
|
||||
if (Object.keys(prefsToSave).length) {
|
||||
fetch('/api/user_preferences.php', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
|
||||
body: JSON.stringify({ preferences: prefsToSave })
|
||||
}).catch(function() {});
|
||||
}
|
||||
if (typeof saveSettings === 'function') saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user