Release notes: create Jira tickets from the header, targeting the project and epic found in JQL, with title, dates, and assignee.
1083 lines
42 KiB
JavaScript
1083 lines
42 KiB
JavaScript
const API_BASE = '';
|
|
let allTickets = [];
|
|
let filteredTickets = [];
|
|
let customFieldsMap = [];
|
|
let lastUsername = '';
|
|
let lastApiToken = '';
|
|
let currentGanttView = 'month';
|
|
let isGanttExpanded = false;
|
|
let currentSortCol = null;
|
|
let isAsc = true;
|
|
|
|
|
|
// --- JIRA STATUS CONFIG & MAPPING ---
|
|
const STATUS_DEFINITIONS = {
|
|
'Opportunity': {
|
|
label: 'Opportunity',
|
|
cssClass: 's-opportunity',
|
|
dark: {
|
|
color: '#c084fc',
|
|
bg: 'rgba(192, 132, 252, 0.16)',
|
|
border: '#a855f7',
|
|
badgeBg: '#9333ea',
|
|
badgeText: '#ffffff'
|
|
},
|
|
light: {
|
|
color: '#6d28d9',
|
|
bg: '#f5f3ff',
|
|
border: '#7c3aed',
|
|
badgeBg: '#7c3aed',
|
|
badgeText: '#ffffff'
|
|
},
|
|
drawioColor: '#9333ea'
|
|
},
|
|
'Delayed': {
|
|
label: 'Delayed',
|
|
cssClass: 's-delayed',
|
|
dark: {
|
|
color: '#94a3b8',
|
|
bg: 'rgba(148, 163, 184, 0.16)',
|
|
border: '#64748b',
|
|
badgeBg: '#475569',
|
|
badgeText: '#ffffff'
|
|
},
|
|
light: {
|
|
color: '#334155',
|
|
bg: '#f1f5f9',
|
|
border: '#64748b',
|
|
badgeBg: '#475569',
|
|
badgeText: '#ffffff'
|
|
},
|
|
drawioColor: '#64748b'
|
|
},
|
|
'To be Analysed': {
|
|
label: 'To be Analysed',
|
|
cssClass: 's-analyse',
|
|
dark: {
|
|
color: '#fbbf24',
|
|
bg: 'rgba(251, 191, 36, 0.16)',
|
|
border: '#f59e0b',
|
|
badgeBg: '#d97706',
|
|
badgeText: '#ffffff'
|
|
},
|
|
light: {
|
|
color: '#92400e',
|
|
bg: '#fffbeb',
|
|
border: '#d97706',
|
|
badgeBg: '#b45309',
|
|
badgeText: '#ffffff'
|
|
},
|
|
drawioColor: '#d97706'
|
|
},
|
|
'Analysed': {
|
|
label: 'Analysed',
|
|
cssClass: 's-analysed',
|
|
dark: {
|
|
color: '#22d3ee',
|
|
bg: 'rgba(34, 211, 238, 0.16)',
|
|
border: '#06b6d4',
|
|
badgeBg: '#0891b2',
|
|
badgeText: '#ffffff'
|
|
},
|
|
light: {
|
|
color: '#0e7490',
|
|
bg: '#ecfeff',
|
|
border: '#0891b2',
|
|
badgeBg: '#0e7490',
|
|
badgeText: '#ffffff'
|
|
},
|
|
drawioColor: '#0891b2'
|
|
},
|
|
'Project Ongoing': {
|
|
label: 'Project Ongoing',
|
|
cssClass: 's-ongoing',
|
|
dark: {
|
|
color: '#60a5fa',
|
|
bg: 'rgba(96, 165, 250, 0.20)',
|
|
border: '#3b82f6',
|
|
badgeBg: '#2563eb',
|
|
badgeText: '#ffffff'
|
|
},
|
|
light: {
|
|
color: '#1e40af',
|
|
bg: '#eff6ff',
|
|
border: '#2563eb',
|
|
badgeBg: '#1d4ed8',
|
|
badgeText: '#ffffff'
|
|
},
|
|
drawioColor: '#2563eb'
|
|
},
|
|
'Rejected': {
|
|
label: 'Rejected',
|
|
cssClass: 's-rejected',
|
|
dark: {
|
|
color: '#f87171',
|
|
bg: 'rgba(248, 113, 113, 0.18)',
|
|
border: '#ef4444',
|
|
badgeBg: '#dc2626',
|
|
badgeText: '#ffffff'
|
|
},
|
|
light: {
|
|
color: '#991b1b',
|
|
bg: '#fef2f2',
|
|
border: '#dc2626',
|
|
badgeBg: '#dc2626',
|
|
badgeText: '#ffffff'
|
|
},
|
|
drawioColor: '#dc2626'
|
|
},
|
|
'Done': {
|
|
label: 'Done',
|
|
cssClass: 's-done',
|
|
dark: {
|
|
color: '#34d399',
|
|
bg: 'rgba(52, 211, 153, 0.18)',
|
|
border: '#10b981',
|
|
badgeBg: '#059669',
|
|
badgeText: '#ffffff'
|
|
},
|
|
light: {
|
|
color: '#065f46',
|
|
bg: '#ecfdf5',
|
|
border: '#059669',
|
|
badgeBg: '#047857',
|
|
badgeText: '#ffffff'
|
|
},
|
|
drawioColor: '#059669'
|
|
}
|
|
};
|
|
|
|
function mapJiraStatus(rawStatus) {
|
|
if (!rawStatus) return 'Opportunity';
|
|
const s = String(rawStatus).trim().toLowerCase();
|
|
|
|
// "Resolved" and "Closed" => DONE
|
|
if (s.includes('done') || s.includes('resolv') || s.includes('clos') || s.includes('ferm') || s.includes('termin') || s.includes('rsolu')) {
|
|
return 'Done';
|
|
}
|
|
// "Rejected" => Rejected
|
|
if (s.includes('reject') || s.includes('rejet') || s.includes('refus') || s.includes('abandon')) {
|
|
return 'Rejected';
|
|
}
|
|
// "In Progress" => Project Ongoing
|
|
if (s.includes('progress') || s.includes('en cours') || s.includes('ongoing') || s.includes('review') || s.includes('dev')) {
|
|
return 'Project Ongoing';
|
|
}
|
|
// "On Hold" => Analysed
|
|
if (s.includes('hold') || s.includes('analysé') || s.includes('analysed') || s.includes('attente')) {
|
|
return 'Analysed';
|
|
}
|
|
// "Open" and "SPQI" => To be Analysed
|
|
if (s === 'open' || s.includes('spqi') || s.includes('ouvert') || s.includes('to analyse') || s.includes('to be analysed')) {
|
|
return 'To be Analysed';
|
|
}
|
|
// "Delayed" => Delayed
|
|
if (s.includes('delay') || s.includes('retard') || s.includes('reporté')) {
|
|
return 'Delayed';
|
|
}
|
|
// "New" and "Reopened" => Opportunity
|
|
if (s.includes('new') || s.includes('reopen') || s.includes('nouveau') || s.includes('opportunit') || s.includes('backlog')) {
|
|
return 'Opportunity';
|
|
}
|
|
|
|
return 'Opportunity';
|
|
}
|
|
|
|
function getStatusThemeConfig(mappedStatus) {
|
|
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
const def = STATUS_DEFINITIONS[mappedStatus] || STATUS_DEFINITIONS['Opportunity'];
|
|
return isLight ? def.light : def.dark;
|
|
}
|
|
|
|
function getStatusClass(s) {
|
|
const mapped = mapJiraStatus(s);
|
|
const def = STATUS_DEFINITIONS[mapped];
|
|
return def ? def.cssClass : 's-opportunity';
|
|
}
|
|
|
|
// --- THEME MANAGEMENT (LIGHT / DARK) ---
|
|
function initTheme() {
|
|
const savedTheme = localStorage.getItem('jira_extractor_theme') || 'dark';
|
|
setTheme(savedTheme, false);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
|
setTheme(newTheme, true);
|
|
}
|
|
|
|
function setTheme(theme, withToast = false) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
localStorage.setItem('jira_extractor_theme', theme);
|
|
|
|
const icon = document.getElementById('themeIcon');
|
|
const label = document.getElementById('themeLabel');
|
|
if (icon && label) {
|
|
if (theme === 'light') {
|
|
icon.className = 'fa-solid fa-moon';
|
|
label.textContent = 'Dark mode';
|
|
} else {
|
|
icon.className = 'fa-solid fa-sun';
|
|
label.textContent = 'Light mode';
|
|
}
|
|
}
|
|
|
|
if (document.getElementById('panel-roadmap') && document.getElementById('panel-roadmap').classList.contains('active')) {
|
|
renderRoadmap();
|
|
}
|
|
|
|
if (withToast) {
|
|
showToast(theme === 'light' ? 'Light mode enabled' : 'Dark mode enabled');
|
|
}
|
|
}
|
|
|
|
// Save settings to local storage
|
|
function saveSettings() {
|
|
const settings = {
|
|
projectKey: document.getElementById('projectKey').value,
|
|
customFields: document.getElementById('customFields').value,
|
|
jqlQuery: document.getElementById('jqlQuery').value
|
|
};
|
|
localStorage.setItem('jira_roadmap_settings', JSON.stringify(settings));
|
|
}
|
|
|
|
// Load settings on startup
|
|
function loadSettings() {
|
|
const saved = localStorage.getItem('jira_roadmap_settings');
|
|
if (saved) {
|
|
const settings = JSON.parse(saved);
|
|
document.getElementById('projectKey').value = settings.projectKey || '';
|
|
document.getElementById('customFields').value = settings.customFields || '';
|
|
document.getElementById('jqlQuery').value = settings.jqlQuery || '';
|
|
}
|
|
}
|
|
|
|
|
|
// --- UTILITIES ---
|
|
function showToast(msg, type='success') {
|
|
const c = document.getElementById('toastContainer');
|
|
const t = document.createElement('div');
|
|
t.className = `toast toast-${type}`;
|
|
t.innerHTML = `<i class="fa-solid fa-${type==='success'?'check-circle':'xmark-circle'}"></i><span>${msg}</span>`;
|
|
c.appendChild(t);
|
|
setTimeout(() => t.remove(), 3500);
|
|
}
|
|
|
|
function autoJql() {
|
|
const key = document.getElementById('projectKey').value.trim().toUpperCase();
|
|
const jql = document.getElementById('jqlQuery');
|
|
if (!jql.value.trim() || jql.value.trim().startsWith('project =')) {
|
|
jql.value = `project = "${key}" ORDER BY created DESC`;
|
|
}
|
|
}
|
|
|
|
async function pingApi() {
|
|
saveSettings();
|
|
|
|
const btn = event.target.closest('.btn');
|
|
const orig = btn.innerHTML; btn.innerHTML = '<span class="spinner"></span> Testing...'; btn.disabled = true;
|
|
try {
|
|
const r = await fetch(`${API_BASE}/api/extract`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({username:'_', apiToken:'_', projectKey:'_'}) });
|
|
if (r.status === 400) showToast('Local API connected!', 'success');
|
|
else showToast('API responded: ' + r.status, 'success');
|
|
} catch(e) {
|
|
showToast('API unreachable. Please run "npm start".', 'error');
|
|
} finally { btn.innerHTML = orig; btn.disabled = false; }
|
|
}
|
|
|
|
async function startExtraction() {
|
|
saveSettings();
|
|
|
|
const btn = document.getElementById('extractBtn');
|
|
const user = document.getElementById('username').value.trim();
|
|
const token = document.getElementById('apiToken').value.trim();
|
|
const project = document.getElementById('projectKey').value.trim().toUpperCase();
|
|
const jql = document.getElementById('jqlQuery').value.trim();
|
|
const custom = document.getElementById('customFields').value.trim();
|
|
|
|
if (!user || !token || !project) { showToast('Required fields missing.', 'error'); return; }
|
|
|
|
btn.innerHTML = '<span class="spinner"></span> Extracting...'; btn.disabled = true;
|
|
|
|
try {
|
|
const resp = await fetch(`${API_BASE}/api/extract`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: user, apiToken: token, projectKey: project, jql: jql, customFields: custom })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || `Error ${resp.status}`);
|
|
|
|
allTickets = data.tickets;
|
|
filteredTickets = [...allTickets];
|
|
customFieldsMap = parseCustomFields(custom);
|
|
lastUsername = user;
|
|
lastApiToken = token;
|
|
|
|
renderResults();
|
|
showToast(`${allTickets.length} tickets extracted.`, 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
btn.innerHTML = '<i class="fa-solid fa-download"></i> Start Extraction'; btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function parseCustomFields(customFields) {
|
|
const map = [];
|
|
if (customFields) {
|
|
customFields.split(',').forEach(cf => {
|
|
const [id, label] = cf.split(':').map(s => s.trim());
|
|
if (id) map.push({ id, label: label || id });
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function sortTable(colKey) {
|
|
if (currentSortCol === colKey) {
|
|
isAsc = !isAsc;
|
|
} else {
|
|
currentSortCol = colKey;
|
|
isAsc = true;
|
|
}
|
|
|
|
filteredTickets.sort((a, b) => {
|
|
let valA = a[colKey] || '';
|
|
let valB = b[colKey] || '';
|
|
|
|
if (colKey.toLowerCase().includes('date') || colKey === 'created' || colKey === 'dueDate') {
|
|
valA = new Date(valA);
|
|
valB = new Date(valB);
|
|
}
|
|
|
|
if (valA < valB) return isAsc ? -1 : 1;
|
|
if (valA > valB) return isAsc ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
renderTable();
|
|
}
|
|
|
|
// --- TICKET UPDATE ---
|
|
async function updateTicket(ticketKey, dueDate, goLiveDate) {
|
|
if (!lastUsername || !lastApiToken) {
|
|
showToast('Please extract tickets first.', 'error');
|
|
return;
|
|
}
|
|
|
|
const btn = document.querySelector(`[data-ticket="${ticketKey}"]`);
|
|
const orig = btn ? btn.innerHTML : null;
|
|
if (btn) { btn.innerHTML = '<span class="spinner"></span>'; btn.disabled = true; }
|
|
|
|
const goLiveField = customFieldsMap.find(cf => cf.label.toLowerCase().includes('go live'));
|
|
const updates = {};
|
|
if (dueDate !== undefined) updates.duedate = dueDate;
|
|
if (goLiveDate !== undefined && goLiveField) updates[goLiveField.id] = goLiveDate;
|
|
|
|
try {
|
|
const resp = await fetch(`${API_BASE}/api/update`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username: lastUsername, apiToken: lastApiToken, ticketKey, updates })
|
|
});
|
|
if (!resp.ok) {
|
|
const data = await resp.json();
|
|
throw new Error(data.error || `HTTP Error ${resp.status}`);
|
|
}
|
|
|
|
const ticket = allTickets.find(t => t.key === ticketKey);
|
|
if (ticket) {
|
|
if (updates.duedate !== undefined) ticket.dueDate = updates.duedate;
|
|
if (goLiveField && updates[goLiveField.id] !== undefined) ticket[goLiveField.label] = updates[goLiveField.id];
|
|
}
|
|
|
|
renderTable();
|
|
renderRoadmap();
|
|
showToast(`Ticket ${ticketKey} updated!`, 'success');
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
if (btn) { btn.innerHTML = orig; btn.disabled = false; }
|
|
}
|
|
}
|
|
|
|
function enableUpdateBtn(ticketKey) {
|
|
const btn = document.getElementById(`update-${ticketKey}`);
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
|
|
// --- TICKET CREATION ---
|
|
function getCreateIssueContext() {
|
|
const jql = document.getElementById('jqlQuery').value.trim();
|
|
const fallbackProject = document.getElementById('projectKey').value.trim().toUpperCase();
|
|
const projectMatch = jql.match(/\bproject\s*=\s*["']?([A-Z][A-Z0-9_-]*)["']?/i);
|
|
const epicMatch = jql.match(/(?:["']?Epic Link["']?|\bparent|cf\[\d+\])\s*=\s*["']?([A-Z][A-Z0-9_-]*-\d+)["']?/i);
|
|
const configuredFields = parseCustomFields(document.getElementById('customFields').value.trim());
|
|
const dateField = configuredFields.find(field => /date|go live|production/i.test(field.label)) || configuredFields[0] || null;
|
|
|
|
return {
|
|
jql,
|
|
projectKey: (projectMatch?.[1] || fallbackProject).toUpperCase(),
|
|
epicKey: epicMatch?.[1]?.toUpperCase() || '',
|
|
dateField
|
|
};
|
|
}
|
|
|
|
function openCreateIssueModal() {
|
|
const username = document.getElementById('username').value.trim();
|
|
const apiToken = document.getElementById('apiToken').value.trim();
|
|
const context = getCreateIssueContext();
|
|
|
|
if (!username || !apiToken) {
|
|
showToast('Enter your Jira credentials first.', 'error');
|
|
return;
|
|
}
|
|
if (!context.projectKey) {
|
|
showToast('Add a project to the JQL query first.', 'error');
|
|
return;
|
|
}
|
|
|
|
saveSettings();
|
|
document.getElementById('createIssueTarget').textContent = context.epicKey
|
|
? `Project ${context.projectKey} · Epic ${context.epicKey}`
|
|
: `Project ${context.projectKey}`;
|
|
document.getElementById('createIssueSummary').value = '';
|
|
document.getElementById('createIssueDueDate').value = '';
|
|
document.getElementById('createIssueGoLiveDate').value = '';
|
|
document.getElementById('createIssueAssignee').value = '';
|
|
|
|
const customDateInput = document.getElementById('createIssueGoLiveDate');
|
|
document.getElementById('createIssueGoLiveLabel').textContent = context.dateField?.label || 'Custom Date';
|
|
customDateInput.disabled = !context.dateField;
|
|
customDateInput.title = context.dateField ? '' : 'Configure a custom date field in Extraction Settings';
|
|
|
|
document.getElementById('createIssueModal').classList.add('visible');
|
|
document.getElementById('createIssueSummary').focus();
|
|
}
|
|
|
|
function closeCreateIssueModal() {
|
|
document.getElementById('createIssueModal').classList.remove('visible');
|
|
}
|
|
|
|
async function createIssue() {
|
|
const summary = document.getElementById('createIssueSummary').value.trim();
|
|
if (!summary) {
|
|
showToast('The ticket title is required.', 'error');
|
|
document.getElementById('createIssueSummary').focus();
|
|
return;
|
|
}
|
|
|
|
const context = getCreateIssueContext();
|
|
const btn = document.getElementById('createIssueSaveBtn');
|
|
const originalContent = btn.innerHTML;
|
|
btn.innerHTML = '<span class="spinner"></span> Creating...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/api/issues`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username: document.getElementById('username').value.trim(),
|
|
apiToken: document.getElementById('apiToken').value.trim(),
|
|
projectKey: context.projectKey,
|
|
jql: context.jql,
|
|
summary,
|
|
dueDate: document.getElementById('createIssueDueDate').value || null,
|
|
goLiveDate: document.getElementById('createIssueGoLiveDate').value || null,
|
|
goLiveFieldId: context.dateField?.id || null,
|
|
assignee: document.getElementById('createIssueAssignee').value.trim() || null
|
|
})
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || `HTTP Error ${response.status}`);
|
|
|
|
closeCreateIssueModal();
|
|
showToast(`Ticket ${data.key} created!`, 'success');
|
|
} catch (error) {
|
|
showToast(error.message, 'error');
|
|
} finally {
|
|
btn.innerHTML = originalContent;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function handleUpdateClick(ticketKey, goLiveColName) {
|
|
const dueInput = document.getElementById(`due-${ticketKey}`);
|
|
const dueDate = dueInput && dueInput.value ? dueInput.value : null;
|
|
|
|
let goLiveDate = null;
|
|
if (goLiveColName) {
|
|
const goLiveInput = document.getElementById(`gl-${ticketKey}`);
|
|
goLiveDate = goLiveInput && goLiveInput.value ? goLiveInput.value : null;
|
|
}
|
|
updateTicket(ticketKey, dueDate, goLiveDate);
|
|
}
|
|
|
|
// --- GANTT DATE EDIT MODAL ---
|
|
let dateEditContext = { ticketKey: null, goLiveColName: null };
|
|
|
|
// Detects the real go-live custom field name, ignoring known built-in ticket properties
|
|
function getGoLiveColumnName() {
|
|
if (!allTickets.length) return null;
|
|
return Object.keys(allTickets[0]).find(key =>
|
|
!['key','url','summary','description','status','statusCategory','assignee','reporter','priority','issueType','created','updated','dueDate','resolution','resolutionDate','labels','components','fixVersions','projectName'].includes(key)
|
|
) || null;
|
|
}
|
|
|
|
function openDateEditModal(ticketKey) {
|
|
const ticket = allTickets.find(t => t.key === ticketKey);
|
|
if (!ticket) return;
|
|
|
|
const goLiveColName = getGoLiveColumnName();
|
|
dateEditContext = { ticketKey, goLiveColName };
|
|
|
|
document.getElementById('dateEditTicketKey').textContent = ticket.key;
|
|
document.getElementById('dateEditTicketSummary').textContent = ticket.summary || '';
|
|
document.getElementById('modalDueDate').value = ticket.dueDate ? ticket.dueDate.substring(0, 10) : '';
|
|
|
|
const goLiveGroup = document.getElementById('modalGoLiveGroup');
|
|
if (goLiveColName) {
|
|
goLiveGroup.style.display = '';
|
|
document.getElementById('modalGoLiveLabel').textContent = goLiveColName;
|
|
document.getElementById('modalGoLiveDate').value = ticket[goLiveColName] ? ticket[goLiveColName].substring(0, 10) : '';
|
|
} else {
|
|
goLiveGroup.style.display = 'none';
|
|
}
|
|
|
|
document.getElementById('dateEditModal').classList.add('visible');
|
|
}
|
|
|
|
function closeDateEditModal() {
|
|
document.getElementById('dateEditModal').classList.remove('visible');
|
|
dateEditContext = { ticketKey: null, goLiveColName: null };
|
|
}
|
|
|
|
async function saveDateEditModal() {
|
|
const { ticketKey, goLiveColName } = dateEditContext;
|
|
if (!ticketKey) return;
|
|
|
|
const dueDate = document.getElementById('modalDueDate').value || null;
|
|
const goLiveDate = goLiveColName ? (document.getElementById('modalGoLiveDate').value || null) : undefined;
|
|
|
|
const btn = document.getElementById('modalSaveBtn');
|
|
const orig = btn.innerHTML;
|
|
btn.innerHTML = '<span class="spinner"></span>';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
await updateTicket(ticketKey, dueDate, goLiveDate);
|
|
closeDateEditModal();
|
|
} finally {
|
|
btn.innerHTML = orig;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// --- RENDERING ---
|
|
function renderResults() {
|
|
document.getElementById('results-section').classList.add('visible');
|
|
renderStats(); renderTable(); renderJSON(); renderRoadmap();
|
|
}
|
|
|
|
function renderStats() {
|
|
const unassigned = allTickets.filter(t => !t.assignee).length;
|
|
const now = new Date();
|
|
const overdue = allTickets.filter(t => t.dueDate && mapJiraStatus(t.status) !== 'Done' && new Date(t.dueDate) < now).length;
|
|
document.getElementById('statsRow').innerHTML = `
|
|
<div class="stat-card"><div class="stat-value">${allTickets.length}</div><div class="stat-label">Tickets</div></div>
|
|
<div class="stat-card"><div class="stat-value">${new Set(allTickets.map(t=>mapJiraStatus(t.status))).size}</div><div class="stat-label">Statuses</div></div>
|
|
<div class="stat-card"><div class="stat-value">${unassigned}</div><div class="stat-label">Unassigned</div></div>
|
|
<div class="stat-card"><div class="stat-value" style="color:${overdue>0?'var(--danger)':'var(--accent)'}">${overdue}</div><div class="stat-label">Overdue</div></div>
|
|
`;
|
|
}
|
|
|
|
function escHtml(s) { if(!s) return ''; const d=document.createElement('div'); d.textContent=String(s); return d.innerHTML; }
|
|
function fmtDate(d) { if(!d) return '<span class="empty-cell">-</span>'; try { return new Date(d).toLocaleDateString('en-US',{day:'2-digit',month:'short',year:'numeric'}); } catch { return escHtml(d); } }
|
|
|
|
function renderTable() {
|
|
const goLiveColName = getGoLiveColumnName();
|
|
|
|
const colMapping = {
|
|
'Ticket': 'key',
|
|
'Summary': 'summary',
|
|
'Status': 'status',
|
|
'Assignee': 'assignee',
|
|
'Priority': 'priority',
|
|
'Type': 'issueType',
|
|
'Due Date': 'dueDate'
|
|
};
|
|
|
|
const cols = ['Ticket','Summary','Status','Assignee','Priority','Type','Due Date'];
|
|
if (goLiveColName) {
|
|
cols.push(goLiveColName);
|
|
colMapping[goLiveColName] = goLiveColName;
|
|
}
|
|
cols.push('Actions');
|
|
|
|
document.getElementById('tableHead').innerHTML = '<tr>' + cols.map(c => {
|
|
const key = colMapping[c];
|
|
if (!key) return `<th>${c}</th>`;
|
|
|
|
const icon = currentSortCol === key ? (isAsc ? ' ▴' : ' ▾') : '';
|
|
return `<th onclick="sortTable('${key}')" style="cursor:pointer" class="sortable-th">
|
|
${c}${icon}
|
|
</th>`;
|
|
}).join('') + '</tr>';
|
|
|
|
let body = '';
|
|
filteredTickets.forEach(t => {
|
|
const dueDateValue = t.dueDate ? t.dueDate.substring(0, 10) : '';
|
|
const mappedStatus = mapJiraStatus(t.status);
|
|
body += `<tr>
|
|
<td><span class="ticket-key" onclick="window.open('${t.url}', '_blank')">${escHtml(t.key)}</span></td>
|
|
<td title="${escHtml(t.summary)}">${escHtml(t.summary)}</td>
|
|
<td>
|
|
<span class="status-badge ${getStatusClass(t.status)}" title="Jira: ${escHtml(t.status)}">
|
|
${escHtml(mappedStatus)}
|
|
</span>
|
|
</td>
|
|
<td>${escHtml(t.assignee) || '-'}</td>
|
|
<td>${escHtml(t.priority)}</td>
|
|
<td>${escHtml(t.issueType)}</td>
|
|
<td><input type="date" class="date-input" id="due-${t.key}" value="${dueDateValue}" onchange="enableUpdateBtn('${t.key}')"></td>`;
|
|
|
|
if (goLiveColName) {
|
|
const glVal = t[goLiveColName] ? t[goLiveColName].substring(0, 10) : '';
|
|
body += `<td><input type="date" class="date-input" id="gl-${t.key}" value="${glVal}" onchange="enableUpdateBtn('${t.key}')"></td>`;
|
|
}
|
|
|
|
body += `<td>
|
|
<button class="btn-update" id="update-${t.key}" data-ticket="${t.key}" onclick="handleUpdateClick('${t.key}', '${goLiveColName || ''}')" disabled>
|
|
<i class="fa-solid fa-rotate"></i> Update
|
|
</button>
|
|
</td></tr>`;
|
|
});
|
|
document.getElementById('tableBody').innerHTML = body || '<tr><td colspan="10">No results found</td></tr>';
|
|
}
|
|
|
|
// --- ROADMAP (GANTT) ---
|
|
function setGanttView(view) {
|
|
currentGanttView = view;
|
|
renderRoadmap();
|
|
}
|
|
|
|
function toggleGanttExpand() {
|
|
isGanttExpanded = !isGanttExpanded;
|
|
updateGanttExpandState();
|
|
}
|
|
|
|
function updateGanttExpandState() {
|
|
const container = document.querySelector('.gantt-container');
|
|
const icon = document.getElementById('ganttExpandIcon');
|
|
const label = document.getElementById('ganttExpandLabel');
|
|
const btn = document.getElementById('ganttExpandBtn');
|
|
|
|
if (container) {
|
|
if (isGanttExpanded) {
|
|
container.classList.add('expanded');
|
|
} else {
|
|
container.classList.remove('expanded');
|
|
}
|
|
}
|
|
|
|
if (icon && label) {
|
|
if (isGanttExpanded) {
|
|
icon.className = 'fa-solid fa-down-left-and-up-right-to-center';
|
|
label.textContent = 'Collapse';
|
|
if (btn) btn.classList.add('active');
|
|
} else {
|
|
icon.className = 'fa-solid fa-up-right-and-down-left-from-center';
|
|
label.textContent = 'Expand';
|
|
if (btn) btn.classList.remove('active');
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GANTT UTILITIES ---
|
|
|
|
/**
|
|
* Extracts short title and project type according to the pattern: "Title - Type - Amount - ID"
|
|
*/
|
|
function parseTicketSummary(summary) {
|
|
if (!summary) return { shortTitle: 'Untitled', type: 'OTHER' };
|
|
const parts = summary.split(' - ');
|
|
const shortTitle = parts[0].trim();
|
|
const typePart = parts[1] ? parts[1].trim().toUpperCase() : 'OTHER';
|
|
|
|
const type = ['PR', 'CR', 'TR'].includes(typePart) ? typePart : 'OTHER';
|
|
return { shortTitle, type };
|
|
}
|
|
|
|
/**
|
|
* Defines activity type accent color
|
|
*/
|
|
function getTypeColor(type) {
|
|
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
switch(type) {
|
|
case 'PR': return isLight ? '#0284c7' : '#38bdf8'; // Blue
|
|
case 'CR': return isLight ? '#b45309' : '#fbbf24'; // Amber
|
|
case 'TR': return isLight ? '#6d28d9' : '#c084fc'; // Purple
|
|
default: return isLight ? '#475569' : '#94a3b8'; // Gray
|
|
}
|
|
}
|
|
|
|
/**
|
|
* MAIN ROADMAP RENDERING FUNCTION
|
|
*/
|
|
function renderRoadmap() {
|
|
const preview = document.getElementById('roadmapPreview');
|
|
if (!filteredTickets || filteredTickets.length === 0) {
|
|
preview.innerHTML = `<div style="text-align:center; padding:60px; color:var(--fg-dim);">No tickets to display.</div>`;
|
|
return;
|
|
}
|
|
|
|
const goLiveColName = Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) || "Go Live Date";
|
|
|
|
const leftWidth = 280;
|
|
let colWidth = 140;
|
|
if (currentGanttView === 'quarter') colWidth = 200;
|
|
if (currentGanttView === 'year') colWidth = 250;
|
|
|
|
// Filter tickets with at least one date
|
|
const ticketsWithDates = filteredTickets.filter(t => {
|
|
const d1 = t.dueDate ? new Date(t.dueDate) : null;
|
|
const d2 = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
|
|
return (d1 && !isNaN(d1.getTime())) || (d2 && !isNaN(d2.getTime()));
|
|
});
|
|
|
|
if (ticketsWithDates.length === 0) {
|
|
preview.innerHTML = `<div style="padding:40px; text-align:center; color:var(--fg-dim);">No Due Date or Go Live date found for filtered tickets.</div>`;
|
|
return;
|
|
}
|
|
|
|
const allDates = ticketsWithDates.flatMap(t => [
|
|
t.dueDate ? new Date(t.dueDate) : null,
|
|
t[goLiveColName] ? new Date(t[goLiveColName]) : null
|
|
].filter(d => d !== null));
|
|
|
|
let minDate = new Date(Math.min(...allDates));
|
|
let maxDate = new Date(Math.max(...allDates));
|
|
|
|
minDate.setDate(1);
|
|
if (currentGanttView === 'quarter') minDate.setMonth(Math.floor(minDate.getMonth() / 3) * 3);
|
|
if (currentGanttView === 'year') minDate.setMonth(0);
|
|
|
|
maxDate.setMonth(maxDate.getMonth() + 2);
|
|
maxDate.setDate(0);
|
|
|
|
const timeScale = [];
|
|
let tempDate = new Date(minDate);
|
|
while (tempDate <= maxDate) {
|
|
let label = "", key = "";
|
|
if (currentGanttView === 'month') {
|
|
label = tempDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
|
key = `${tempDate.getFullYear()}-${tempDate.getMonth()}`;
|
|
tempDate.setMonth(tempDate.getMonth() + 1);
|
|
} else if (currentGanttView === 'quarter') {
|
|
const q = Math.floor(tempDate.getMonth() / 3) + 1;
|
|
label = `Q${q} ${tempDate.getFullYear()}`;
|
|
key = `${tempDate.getFullYear()}-Q${q}`;
|
|
tempDate.setMonth(tempDate.getMonth() + 3);
|
|
} else if (currentGanttView === 'year') {
|
|
label = `${tempDate.getFullYear()}`;
|
|
key = `${tempDate.getFullYear()}`;
|
|
tempDate.setFullYear(tempDate.getFullYear() + 1);
|
|
}
|
|
timeScale.push({ key, label });
|
|
}
|
|
|
|
const getXPos = (date) => {
|
|
if (!date) return 0;
|
|
const d = new Date(date);
|
|
let index = -1;
|
|
if (currentGanttView === 'month') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-${d.getMonth()}`);
|
|
else if (currentGanttView === 'quarter') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`);
|
|
else if (currentGanttView === 'year') index = timeScale.findIndex(m => m.key === `${d.getFullYear()}`);
|
|
|
|
if (index === -1) return 0;
|
|
|
|
let ratio = 0;
|
|
if (currentGanttView === 'month') ratio = (d.getDate() - 1) / 30;
|
|
else if (currentGanttView === 'quarter') ratio = ((d.getMonth() % 3) * 30 + d.getDate()) / 90;
|
|
else if (currentGanttView === 'year') ratio = (d.getMonth() * 30 + d.getDate()) / 365;
|
|
|
|
return leftWidth + (index * colWidth) + (ratio * colWidth);
|
|
};
|
|
|
|
const grouped = ticketsWithDates.reduce((acc, t) => {
|
|
const name = t.assignee || "Unassigned";
|
|
if (!acc[name]) acc[name] = [];
|
|
acc[name].push(t);
|
|
return acc;
|
|
}, {});
|
|
|
|
let html = `<div class="gantt-container ${isGanttExpanded ? 'expanded' : ''}"><div class="gantt-canvas" style="width: ${leftWidth + (timeScale.length * colWidth)}px;">`;
|
|
|
|
const todayX = getXPos(new Date());
|
|
if (todayX > leftWidth) {
|
|
html += `<div class="gantt-today-line" style="left: ${todayX}px;"></div>`;
|
|
html += `<div class="gantt-today-label" id="todayMarker" style="left: ${todayX}px;">Today</div>`;
|
|
}
|
|
|
|
html += `<div class="gantt-header"><div class="gantt-sticky-col gantt-group-name" style="width:${leftWidth}px">Assignee / Ticket (Title)</div>`;
|
|
timeScale.forEach(ts => { html += `<div class="gantt-month-header" style="width:${colWidth}px;">${ts.label}</div>`; });
|
|
html += `</div>`;
|
|
|
|
Object.keys(grouped).sort().forEach(assignee => {
|
|
html += `<div class="gantt-group-header">
|
|
<div class="gantt-sticky-col gantt-group-name" style="width:${leftWidth}px">
|
|
<i class="fa-solid fa-user-circle mr-8"></i>${assignee}
|
|
</div>
|
|
</div>`;
|
|
|
|
const sortedTickets = grouped[assignee].sort((a, b) => (new Date(a.dueDate || 0)) - (new Date(b.dueDate || 0)));
|
|
|
|
sortedTickets.forEach(t => {
|
|
const dDue = t.dueDate ? new Date(t.dueDate) : null;
|
|
const dLive = t[goLiveColName] ? new Date(t[goLiveColName]) : null;
|
|
|
|
const start = dDue || dLive;
|
|
const end = dLive || dDue;
|
|
const x1 = getXPos(start);
|
|
const x2 = getXPos(end);
|
|
|
|
const barWidth = Math.max(170, (x2 - x1) + 10);
|
|
|
|
const { shortTitle, type } = parseTicketSummary(t.summary);
|
|
const mappedStatus = mapJiraStatus(t.status);
|
|
const statusCfg = getStatusThemeConfig(mappedStatus);
|
|
const typeColor = getTypeColor(type);
|
|
|
|
html += `<div class="gantt-row">
|
|
<div class="gantt-sticky-col gantt-ticket-id" style="width:${leftWidth}px;" title="${escHtml(t.key)}: ${escHtml(t.summary)}">
|
|
<button class="gantt-date-edit-btn" onclick="event.stopPropagation(); openDateEditModal('${t.key}')" title="Edit dates">
|
|
<i class="fa-solid fa-calendar-days"></i>
|
|
</button>
|
|
<span class="gantt-ticket-id-label">${escHtml(shortTitle)}</span>
|
|
</div>
|
|
|
|
<div class="gantt-bar"
|
|
onclick="window.open('${t.url}', '_blank')"
|
|
title="${escHtml(t.key)}: ${escHtml(t.summary)} | Status: ${escHtml(t.status)} (${mappedStatus}) | Type: ${type}"
|
|
style="left:${x1}px; width:${barWidth}px; background:${statusCfg.bg}; border-color:${statusCfg.border}; border-left:5px solid ${statusCfg.border};">
|
|
|
|
<div class="gantt-status-badge" style="background:${statusCfg.badgeBg}; color:${statusCfg.badgeText};">
|
|
${escHtml(mappedStatus)}
|
|
</div>
|
|
|
|
<span class="gantt-type-tag" style="background:${typeColor}20; color:${typeColor}; border:1px solid ${typeColor}66;">
|
|
${type}
|
|
</span>
|
|
<span class="gantt-bar-title">${escHtml(shortTitle)}</span>
|
|
</div>
|
|
${timeScale.map(() => `<div class="gantt-grid-col" style="width:${colWidth}px;"></div>`).join('')}
|
|
</div>`;
|
|
});
|
|
});
|
|
|
|
html += `</div></div>`;
|
|
preview.innerHTML = html;
|
|
|
|
setTimeout(() => {
|
|
const m = document.getElementById('todayMarker');
|
|
if (m) m.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
}, 150);
|
|
}
|
|
|
|
|
|
// --- TAB & FILTER ---
|
|
function switchTab(id, btn) {
|
|
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
|
btn.classList.add('active'); document.getElementById(`panel-${id}`).classList.add('active');
|
|
if(id === 'roadmap') renderRoadmap();
|
|
}
|
|
|
|
function filterTable() {
|
|
const q = document.getElementById('searchInput').value.toLowerCase();
|
|
filteredTickets = q ? allTickets.filter(t => Object.values(t).some(v => v && String(v).toLowerCase().includes(q))) : [...allTickets];
|
|
renderTable();
|
|
if(document.getElementById('panel-roadmap').classList.contains('active')) renderRoadmap();
|
|
}
|
|
|
|
function exportDrawIOXML(mode) {
|
|
try {
|
|
if (!filteredTickets || filteredTickets.length === 0) {
|
|
showToast("No tickets to export", "error");
|
|
return;
|
|
}
|
|
|
|
const goLiveCol = Object.keys(filteredTickets[0]).find(k => k.toLowerCase().includes('go live')) || "dueDate";
|
|
const ticketsWithDates = filteredTickets.filter(t => t.dueDate || t[goLiveCol]);
|
|
|
|
if (ticketsWithDates.length === 0) {
|
|
showToast("No end dates found", "error");
|
|
return;
|
|
}
|
|
|
|
const leftWidth = 250;
|
|
const rowHeight = 50;
|
|
let pixelPerDay = 12;
|
|
if (currentGanttView === 'quarter') pixelPerDay = 4;
|
|
if (currentGanttView === 'year') pixelPerDay = 1.2;
|
|
|
|
const allDates = ticketsWithDates.map(t => new Date(t.dueDate || t[goLiveCol]));
|
|
let minDate = new Date(Math.min(...allDates));
|
|
minDate.setDate(1);
|
|
let maxDate = new Date(Math.max(...allDates));
|
|
maxDate.setMonth(maxDate.getMonth() + 4);
|
|
|
|
const getX = (dateStr) => {
|
|
const d = new Date(dateStr);
|
|
if (isNaN(d.getTime())) return leftWidth;
|
|
return Math.floor(leftWidth + ((d - minDate) / 86400000 * pixelPerDay));
|
|
};
|
|
|
|
let xmlNodes = '';
|
|
|
|
let curr = new Date(minDate);
|
|
while (curr < maxDate) {
|
|
const xPos = getX(curr);
|
|
let label = "";
|
|
|
|
if (currentGanttView === 'month') {
|
|
label = curr.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }).toUpperCase();
|
|
curr.setMonth(curr.getMonth() + 1);
|
|
} else if (currentGanttView === 'quarter') {
|
|
label = "Q" + (Math.floor(curr.getMonth() / 3) + 1) + " " + curr.getFullYear();
|
|
curr.setMonth(curr.getMonth() + 3);
|
|
} else {
|
|
label = curr.getFullYear();
|
|
curr.setFullYear(curr.getFullYear() + 1);
|
|
}
|
|
|
|
xmlNodes += `<mxCell id="m_${curr.getTime()}" value="${label}" style="text;fontColor=#7a9488;fontSize=14;fontStyle=1;align=left;verticalAlign=middle" vertex="1" parent="1">
|
|
<mxGeometry x="${xPos + 5}" y="5" width="150" height="30" as="geometry"/>
|
|
</mxCell>`;
|
|
|
|
xmlNodes += `<mxCell id="l_${curr.getTime()}" value="" style="line;strokeColor=#E0E0E0;direction=south;html=1;opacity=20" vertex="1" parent="1">
|
|
<mxGeometry x="${xPos}" y="0" width="10" height="4000" as="geometry"/>
|
|
</mxCell>`;
|
|
}
|
|
|
|
const xToday = getX(new Date());
|
|
xmlNodes += `<mxCell id="today" value="TODAY" style="line;strokeColor=#FF0000;direction=south;html=1;dashed=1;strokeWidth=2;fontColor=#FF0000;fontSize=12;fontStyle=1;verticalAlign=top" vertex="1" parent="1">
|
|
<mxGeometry x="${xToday}" y="0" width="10" height="4000" as="geometry"/>
|
|
</mxCell>`;
|
|
|
|
let currentY = 80;
|
|
const groups = ticketsWithDates.reduce((acc, t) => {
|
|
const a = t.assignee || 'Unassigned';
|
|
if (!acc[a]) acc[a] = [];
|
|
acc[a].push(t);
|
|
return acc;
|
|
}, {});
|
|
|
|
Object.keys(groups).sort().forEach(assignee => {
|
|
xmlNodes += `<mxCell id="as_${currentY}" value="${assignee.toUpperCase()}" style="text;fontColor=#00e68a;fontStyle=1;fontSize=14" vertex="1" parent="1">
|
|
<mxGeometry x="10" y="${currentY}" width="200" height="30" as="geometry"/>
|
|
</mxCell>`;
|
|
currentY += 40;
|
|
|
|
groups[assignee].forEach(t => {
|
|
const { shortTitle, type } = parseTicketSummary(t.summary);
|
|
const mappedStatus = mapJiraStatus(t.status);
|
|
const statusDef = STATUS_DEFINITIONS[mappedStatus] || STATUS_DEFINITIONS['Opportunity'];
|
|
const barColor = statusDef.drawioColor;
|
|
|
|
const startDate = t.created ? new Date(t.created) : minDate;
|
|
const endDate = new Date(t.dueDate || t[goLiveCol]);
|
|
|
|
const xStart = getX(startDate);
|
|
const xEnd = getX(endDate);
|
|
let barWidth = xEnd - xStart;
|
|
if (barWidth < 50) barWidth = 140;
|
|
|
|
xmlNodes += `<mxCell id="lbl_${currentY}" value="${escHtml(shortTitle)}" style="text;fontColor=#555555;fontSize=10;align=left;whiteSpace=wrap" vertex="1" parent="1">
|
|
<mxGeometry x="30" y="${currentY}" width="200" height="30" as="geometry"/>
|
|
</mxCell>`;
|
|
|
|
const label = `[${mappedStatus}] [${type}] ${escHtml(shortTitle)}`;
|
|
xmlNodes += `<mxCell id="bar_${currentY}" value="${label}" style="rounded=1;fillColor=${barColor};strokeColor=#FFFFFF;fontColor=#FFFFFF;fontSize=10;fontStyle=1;spacingLeft=8;align=left" vertex="1" parent="1">
|
|
<mxGeometry x="${xStart}" y="${currentY}" width="${barWidth}" height="28" as="geometry"/>
|
|
</mxCell>`;
|
|
|
|
currentY += rowHeight;
|
|
});
|
|
currentY += 20;
|
|
});
|
|
|
|
const finalXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<mxfile host="app.diagrams.net">
|
|
<diagram id="Jira" name="Roadmap">
|
|
<mxGraphModel dx="1000" dy="1000" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="0" background="none">
|
|
<root>
|
|
<mxCell id="0" />
|
|
<mxCell id="1" parent="0" />
|
|
${xmlNodes}
|
|
</root>
|
|
</mxGraphModel>
|
|
</diagram>
|
|
</mxfile>`;
|
|
|
|
if (mode === 'download') {
|
|
const blob = new Blob([finalXml], { type: 'text/xml' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = "jira_export.drawio";
|
|
a.click();
|
|
} else {
|
|
navigator.clipboard.writeText(finalXml).then(() => showToast("XML Copied!"));
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(error);
|
|
showToast("Error during XML export", "error");
|
|
}
|
|
}
|
|
|
|
function renderJSON() { document.getElementById('jsonViewer').innerHTML = syntaxHL(JSON.stringify(allTickets, null, 2)); }
|
|
function syntaxHL(json) {
|
|
json = json.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, m => {
|
|
let c = 'json-number'; if(/^"/.test(m)) c = /:$/.test(m)?'json-key':'json-string'; else if(/true|false/.test(m)) c='json-bool'; else if(/null/.test(m)) c='json-null';
|
|
return `<span class="${c}">${m}</span>`;
|
|
});
|
|
}
|
|
|
|
function downloadJSON() {
|
|
const blob = new Blob([JSON.stringify(allTickets, null, 2)], {type:'application/json'});
|
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
|
a.download = `export-${Date.now()}.json`; a.click();
|
|
}
|
|
|
|
function copyJSON() { navigator.clipboard.writeText(JSON.stringify(allTickets, null, 2)).then(() => showToast('Copied!')); }
|
|
|
|
function clearResults() { allTickets=[]; filteredTickets=[]; document.getElementById('results-section').classList.remove('visible'); }
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();startExtraction()}
|
|
if(e.key === 'Escape') {
|
|
closeDateEditModal();
|
|
closeCreateIssueModal();
|
|
}
|
|
});
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initTheme();
|
|
loadSettings();
|
|
|
|
const modalOverlay = document.getElementById('dateEditModal');
|
|
if (modalOverlay) {
|
|
modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) closeDateEditModal(); });
|
|
}
|
|
|
|
const createIssueOverlay = document.getElementById('createIssueModal');
|
|
if (createIssueOverlay) {
|
|
createIssueOverlay.addEventListener('click', e => { if (e.target === createIssueOverlay) closeCreateIssueModal(); });
|
|
}
|
|
|
|
console.log("Settings loaded from local storage.");
|
|
}); |