04/07 updated linking ticket

This commit is contained in:
2026-04-07 10:11:52 -04:00
parent 299bf2ca05
commit 56c65bfe4c
3 changed files with 225 additions and 13 deletions
+164 -5
View File
@@ -548,6 +548,8 @@ function buildCommentEl(c) {
`;
return wrap;
}
</script>
<style>
@@ -667,12 +669,44 @@ function buildCommentEl(c) {
<!-- Link form (collapsed by default) -->
<div class="collapse mt-3" id="link-form-collapse">
<form method="POST" action="{{ url_for('tickets.link_ticket', ticket_id=ticket.id) }}">
<form method="POST" action="{{ url_for('tickets.link_ticket', ticket_id=ticket.id) }}"
id="link-ticket-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- Hidden field submitted to the server — populated by the picker -->
<input type="hidden" name="linked_ticket_id" id="link-ticket-id"/>
<div class="mb-2">
<label class="form-label" style="font-size:12px;">Ticket ID or Number</label>
<input type="number" class="form-control form-control-sm"
name="linked_ticket_id" placeholder="e.g. 42" required min="1"/>
<label class="form-label" style="font-size:12px;">Search Ticket</label>
<!-- Live search input -->
<div style="position:relative;">
<input type="text" id="link-ticket-search"
class="form-control form-control-sm"
placeholder="Type ticket number or title…"
autocomplete="off"/>
<!-- Results dropdown -->
<div id="link-ticket-results"
style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;
background:#fff;border:1px solid var(--border);border-radius:8px;
box-shadow:0 6px 20px rgba(0,0,0,.12);z-index:200;
max-height:220px;overflow-y:auto;"></div>
</div>
<!-- Selected ticket display -->
<div id="link-selected-ticket"
style="display:none;margin-top:6px;padding:7px 10px;
background:var(--surface2);border:1px solid var(--border);
border-radius:6px;font-size:12px;
display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div>
<span id="link-sel-number" class="mono"
style="color:var(--accent3);font-weight:600;font-size:11px;"></span>
<span id="link-sel-title" style="color:var(--text);margin-left:6px;"></span>
</div>
<button type="button" id="link-clear-btn"
style="background:none;border:none;color:var(--muted);
cursor:pointer;padding:0;font-size:13px;flex-shrink:0;"
title="Clear selection">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="mb-2">
<label class="form-label" style="font-size:12px;">Relationship</label>
@@ -682,7 +716,8 @@ function buildCommentEl(c) {
<option value="follow_up">Follow-up</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100">
<button type="submit" id="link-submit-btn"
class="btn btn-primary btn-sm w-100" disabled>
<i class="bi bi-link-45deg me-1"></i>Create Link
</button>
</form>
@@ -752,6 +787,130 @@ function buildCommentEl(c) {
</div>
</div>
<script>
// ── Link-ticket live search ───────────────────────────────────────────────────
(function () {
const CURRENT_TICKET_ID = {{ ticket.id }};
const searchEl = document.getElementById('link-ticket-search');
const resultsEl = document.getElementById('link-ticket-results');
const idField = document.getElementById('link-ticket-id');
const selCard = document.getElementById('link-selected-ticket');
const selNumber = document.getElementById('link-sel-number');
const selTitle = document.getElementById('link-sel-title');
const clearBtn = document.getElementById('link-clear-btn');
const submitBtn = document.getElementById('link-submit-btn');
if (!searchEl) return; // guard: only runs on detail page with IT staff sidebar
// Auto-focus the search field when the collapse panel opens
const collapseEl = document.getElementById('link-form-collapse');
if (collapseEl) {
collapseEl.addEventListener('shown.bs.collapse', () => searchEl.focus());
}
let debounce = null;
searchEl.addEventListener('input', () => {
clearTimeout(debounce);
const q = searchEl.value.trim();
// Enable submit as soon as there is any text — server resolves ticket_number strings
submitBtn.disabled = (q.length === 0);
if (!idField.value) idField.value = ''; // clear stale selection if user retypes
if (q.length < 1) { resultsEl.style.display = 'none'; return; }
debounce = setTimeout(() => fetchTickets(q), 300);
});
searchEl.addEventListener('focus', () => {
if (searchEl.value.trim().length >= 1) fetchTickets(searchEl.value.trim());
});
document.addEventListener('click', e => {
if (!e.target.closest('#link-ticket-search') && !e.target.closest('#link-ticket-results'))
resultsEl.style.display = 'none';
});
async function fetchTickets(q) {
try {
const r = await fetch(
`/api/tickets/search?q=${encodeURIComponent(q)}&exclude=${CURRENT_TICKET_ID}`,
{ credentials: 'same-origin' }
);
const data = await r.json();
renderResults(data.tickets || []);
} catch { resultsEl.style.display = 'none'; }
}
const STATUS_COLORS = {
open: 'var(--accent)', in_progress: 'var(--info)',
pending: 'var(--warning)', resolved: 'var(--success)', closed: 'var(--muted)'
};
function renderResults(tickets) {
if (!tickets.length) {
resultsEl.innerHTML =
'<div style="padding:10px 12px;font-size:12px;color:var(--muted);">No matching tickets found.</div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = tickets.map(t => `
<div class="link-ticket-option"
data-id="${t.id}" data-number="${t.ticket_number}" data-title="${t.title.replace(/"/g,'&quot;')}"
style="padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:10px;">
<div style="flex:1;min-width:0;">
<span class="mono" style="font-size:11px;color:var(--accent3);font-weight:600;">${t.ticket_number}</span>
<span style="font-size:10px;background:var(--surface2);color:${STATUS_COLORS[t.status] || 'var(--muted)'};
padding:1px 6px;border-radius:4px;margin-left:5px;">${t.status.replace('_',' ')}</span>
<div style="font-size:12px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px;">
${t.title}
</div>
</div>
</div>
`).join('');
resultsEl.querySelectorAll('.link-ticket-option').forEach(el => {
el.addEventListener('mouseenter', () => el.style.background = 'var(--surface2)');
el.addEventListener('mouseleave', () => el.style.background = '');
el.addEventListener('click', () => selectTicket(el.dataset));
});
resultsEl.style.display = 'block';
}
function selectTicket(data) {
idField.value = data.id;
selNumber.textContent = data.number;
selTitle.textContent = data.title;
selCard.style.display = 'flex';
searchEl.style.display = 'none';
resultsEl.style.display = 'none';
submitBtn.disabled = false;
}
clearBtn.addEventListener('click', () => {
idField.value = '';
selCard.style.display = 'none';
searchEl.style.display = '';
searchEl.value = '';
searchEl.focus();
submitBtn.disabled = true;
});
// If user typed a full ticket number without clicking a result,
// populate the hidden field from the search text so the server can resolve it.
document.getElementById('link-ticket-form').addEventListener('submit', e => {
if (!idField.value) {
const typed = searchEl.value.trim();
if (typed) {
idField.value = typed; // server resolves by ticket_number string
} else {
e.preventDefault();
searchEl.focus();
}
}
});
})();
</script>
<!-- Re-open modal trigger — shown to ticket creator when resolved or closed -->
{% if ticket.status in ('resolved', 'closed') and
(current_user.is_it_staff or ticket.created_by_id == current_user.id) %}