783 lines
35 KiB
JavaScript
783 lines
35 KiB
JavaScript
/**
|
|
* Dashboard View
|
|
* Shows stats overview, customizable advanced ticket trends chart, distribution charts, and recent tickets.
|
|
*/
|
|
|
|
// Helper to calculate date boundaries
|
|
function getDatesForPeriod(period, customFromVal, customToVal) {
|
|
let start_date, end_date;
|
|
const today = new Date();
|
|
|
|
if (period === 'last_week') {
|
|
const past = new Date();
|
|
past.setDate(today.getDate() - 7);
|
|
start_date = formatDateString(past);
|
|
end_date = formatDateString(today);
|
|
} else if (period === 'last_month') {
|
|
const past = new Date();
|
|
past.setDate(today.getDate() - 30);
|
|
start_date = formatDateString(past);
|
|
end_date = formatDateString(today);
|
|
} else {
|
|
start_date = customFromVal || formatDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
|
|
end_date = customToVal || formatDateString(today);
|
|
}
|
|
return { start_date, end_date };
|
|
}
|
|
|
|
function formatDateString(d) {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const dateVal = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${dateVal}`;
|
|
}
|
|
|
|
/**
|
|
* Dashboard View
|
|
* Shows stats overview, customizable advanced ticket trends chart, distribution charts, and recent tickets.
|
|
*/
|
|
|
|
// Helper to calculate date boundaries
|
|
function getDatesForPeriod(period, customFromVal, customToVal) {
|
|
let start_date, end_date;
|
|
const today = new Date();
|
|
|
|
if (period === 'last_week') {
|
|
const past = new Date();
|
|
past.setDate(today.getDate() - 7);
|
|
start_date = formatDateString(past);
|
|
end_date = formatDateString(today);
|
|
} else if (period === 'last_month') {
|
|
const past = new Date();
|
|
past.setDate(today.getDate() - 30);
|
|
start_date = formatDateString(past);
|
|
end_date = formatDateString(today);
|
|
} else {
|
|
start_date = customFromVal || formatDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000));
|
|
end_date = customToVal || formatDateString(today);
|
|
}
|
|
return { start_date, end_date };
|
|
}
|
|
|
|
function formatDateString(d) {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const dateVal = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${dateVal}`;
|
|
}
|
|
|
|
const DashboardView = {
|
|
chartInstance: null,
|
|
activeLines: [],
|
|
|
|
async render() {
|
|
const container = document.getElementById('view-container');
|
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
|
|
|
|
try {
|
|
// Ensure lookup tables are loaded in App.lookups
|
|
await App.ensureLookups();
|
|
|
|
const stats = await App.api('/api/dashboard/stats');
|
|
|
|
const maxByState = Math.max(...(stats.by_state || []).map(s => parseInt(s.count)), 1);
|
|
const maxByPriority = Math.max(...(stats.by_priority || []).map(s => parseInt(s.count)), 1);
|
|
const maxByQueue = Math.max(...(stats.by_queue || []).map(s => parseInt(s.count)), 1);
|
|
|
|
// Load saved chart period preferences
|
|
const savedPeriod = localStorage.getItem('otrs_chart_period') || 'last_week';
|
|
const savedDateFrom = localStorage.getItem('otrs_chart_date_from') || '';
|
|
const savedDateTo = localStorage.getItem('otrs_chart_date_to') || '';
|
|
|
|
container.innerHTML = `
|
|
<!-- Custom Ticket Trends Chart Card -->
|
|
<div class="card chart-card" style="margin-bottom: var(--space-md);">
|
|
<div class="chart-header" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:var(--space-md); margin-bottom:var(--space-md);">
|
|
<div class="card-title" style="margin-bottom:0; display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
|
<span>Andamento Ticket</span>
|
|
<button class="btn btn-ghost btn-sm" id="configure-series-btn" style="padding:4px 8px; font-size:0.8rem; display:flex; align-items:center; gap:4px; height:28px;" title="Configura serie di dati del grafico">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
|
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
|
</svg>
|
|
<span>Configura serie di dati</span>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" id="export-series-excel-btn" style="padding:4px 8px; font-size:0.8rem; display:flex; align-items:center; gap:4px; height:28px;" title="Esporta dati in Excel (XLSX)">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
|
</svg>
|
|
<span>Esporta in Excel</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Date controls -->
|
|
<div class="chart-controls" style="display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
|
<select id="chart-period-preset" class="form-select" style="height:32px; font-size:0.85rem; padding: 4px 28px 4px 8px; margin:0; width:170px; background-position: right 8px center;">
|
|
<option value="last_week" ${savedPeriod === 'last_week' ? 'selected' : ''}>Ultima Settimana</option>
|
|
<option value="last_month" ${savedPeriod === 'last_month' ? 'selected' : ''}>Ultimo Mese</option>
|
|
<option value="custom" ${savedPeriod === 'custom' ? 'selected' : ''}>Periodo Personalizzato</option>
|
|
</select>
|
|
|
|
<div id="chart-custom-dates" style="display:${savedPeriod === 'custom' ? 'flex' : 'none'}; align-items:center; gap:6px;">
|
|
<span style="font-size:0.8rem; color:var(--text-secondary);">Da:</span>
|
|
<input type="date" id="chart-date-from" class="form-input" value="${savedDateFrom}" style="height:32px; font-size:0.85rem; padding:4px 8px; margin:0; width:135px;">
|
|
<span style="font-size:0.8rem; color:var(--text-secondary);">A:</span>
|
|
<input type="date" id="chart-date-to" class="form-input" value="${savedDateTo}" style="height:32px; font-size:0.85rem; padding:4px 8px; margin:0; width:135px;">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-canvas-wrapper" style="position:relative; height:300px; width:100%;">
|
|
<canvas id="dashboard-ticket-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card accent">
|
|
<div class="stat-label">Ticket Aperti</div>
|
|
<div class="stat-value">${stats.total_open}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Creati Oggi</div>
|
|
<div class="stat-value">${stats.created_today}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Creati Settimana</div>
|
|
<div class="stat-value">${stats.created_this_week}</div>
|
|
</div>
|
|
<div class="stat-card ${stats.escalated > 0 ? 'danger' : ''}">
|
|
<div class="stat-label">Escalated</div>
|
|
<div class="stat-value">${stats.escalated}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Distributions -->
|
|
<div class="distribution-section">
|
|
<div class="card">
|
|
<div class="card-title">Per Stato</div>
|
|
${(stats.by_state || []).map(s => `
|
|
<div class="dist-bar-container">
|
|
<div class="dist-bar-header">
|
|
<span class="dist-bar-label">${s.state}</span>
|
|
<span class="dist-bar-value">${s.count}</span>
|
|
</div>
|
|
<div class="dist-bar-track">
|
|
<div class="dist-bar-fill" style="width: ${(parseInt(s.count) / maxByState * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
${(stats.by_state || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">Per Priorità</div>
|
|
${(stats.by_priority || []).map((p, idx) => `
|
|
<div class="dist-bar-container">
|
|
<div class="dist-bar-header">
|
|
<span class="dist-bar-label">${p.priority}</span>
|
|
<span class="dist-bar-value">${p.count}</span>
|
|
</div>
|
|
<div class="dist-bar-track">
|
|
<div class="dist-bar-fill" style="width: ${(parseInt(p.count) / maxByPriority * 100).toFixed(1)}%; background: linear-gradient(90deg, var(--priority-${idx + 1}-text, var(--accent-primary)), var(--accent-secondary));"></div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
${(stats.by_priority || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">Per Coda (Top 10)</div>
|
|
${(stats.by_queue || []).map(q => `
|
|
<div class="dist-bar-container">
|
|
<div class="dist-bar-header">
|
|
<span class="dist-bar-label">${q.queue}</span>
|
|
<span class="dist-bar-value">${q.count}</span>
|
|
</div>
|
|
<div class="dist-bar-track">
|
|
<div class="dist-bar-fill" style="width: ${(parseInt(q.count) / maxByQueue * 100).toFixed(1)}%"></div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
${(stats.by_queue || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Tickets -->
|
|
<div class="card">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md); flex-wrap:wrap; gap:var(--space-xs);">
|
|
<div class="card-title" style="margin-bottom:0;">Ticket Recenti</div>
|
|
<div style="display:flex; align-items:center; gap:var(--space-xs); font-size:0.85rem; color:var(--text-secondary);">
|
|
<span>Mostra:</span>
|
|
<select id="dashboard-preview-limit" class="form-select" style="padding: 4px 28px 4px 8px; font-size: 0.8rem; height: 28px; min-width: 70px; margin: 0; background-position: right 8px center; border-color: var(--border-light);">
|
|
<option value="5" ${stats.preview_limit === 5 ? 'selected' : ''}>5</option>
|
|
<option value="10" ${stats.preview_limit === 10 ? 'selected' : ''}>10</option>
|
|
<option value="20" ${stats.preview_limit === 20 ? 'selected' : ''}>20</option>
|
|
<option value="50" ${stats.preview_limit === 50 ? 'selected' : ''}>50</option>
|
|
<option value="100" ${stats.preview_limit === 100 ? 'selected' : ''}>100</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
${(stats.recent_tickets || []).length > 0 ? `
|
|
<table class="recent-tickets-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Numero</th>
|
|
<th>Titolo</th>
|
|
<th>Stato</th>
|
|
<th>Priorità</th>
|
|
<th>Coda</th>
|
|
<th>Data</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${stats.recent_tickets.map(t => `
|
|
<tr onclick="window.location.hash='#/tickets/${t.id}'">
|
|
<td><span class="ticket-tn">${t.tn}</span></td>
|
|
<td class="ticket-title-cell">${App.escapeHtml(t.title || '')}</td>
|
|
<td><span class="badge badge-state" data-state-type="${(t.state_name || '').toLowerCase()}">${t.state_name}</span></td>
|
|
<td><span class="badge badge-priority" data-priority="${t.priority_name ? App.priorityIndex(t.priority_name) : 3}">${t.priority_name}</span></td>
|
|
<td><span class="badge badge-queue">${t.queue_name}</span></td>
|
|
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.create_time)}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
` : `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📭</div>
|
|
<div class="empty-state-text">Nessun ticket recente</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
|
|
<!-- MODAL: Configure Series -->
|
|
<div id="chart-series-modal" style="display:none; position:fixed; inset:0; z-index:8000; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); align-items:center; justify-content:center; padding: 20px;">
|
|
<div class="card" style="width:100%; max-width:680px; background:var(--bg-card); max-height: 90vh; display:flex; flex-direction:column; padding: var(--space-lg); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); border: 1px solid var(--border-light);">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--border-subtle); padding-bottom:var(--space-sm); margin-bottom:var(--space-md); flex-shrink:0;">
|
|
<h3 style="margin:0; font-size:1.1rem; font-weight:600; color:var(--text-primary);">Configura Serie di Dati</h3>
|
|
<button id="chart-series-modal-close" style="background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted); padding:4px;">✕</button>
|
|
</div>
|
|
|
|
<!-- Series List -->
|
|
<div id="modal-series-list-section" style="overflow-y:auto; flex-grow:1;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md);">
|
|
<span style="font-size:0.85rem; color:var(--text-secondary);">Serie di dati attive sul grafico:</span>
|
|
<button class="btn btn-primary btn-sm" id="btn-add-chart-series">+ Aggiungi Serie</button>
|
|
</div>
|
|
<div id="modal-series-list" style="display:flex; flex-direction:column; gap:8px;">
|
|
<!-- populated dynamically -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series Editor Form (hidden by default) -->
|
|
<form id="modal-series-form" style="display:none; flex-direction:column; gap:var(--space-md); overflow-y:auto; flex-grow:1;">
|
|
<input type="hidden" id="form-series-id">
|
|
|
|
<div style="display:grid; grid-template-columns: 2fr 1fr; gap:var(--space-md);">
|
|
<div>
|
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Nome Serie (legenda)</label>
|
|
<input type="text" id="form-series-name" class="form-input" placeholder="Es. Ticket urgenti" required style="width:100%;">
|
|
</div>
|
|
<div>
|
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Colore Grafico</label>
|
|
<input type="color" id="form-series-color" class="form-input" style="width:100%; height:36px; padding: 2px; cursor: pointer; border-radius: var(--radius-sm); border: 1px solid var(--border-light);" value="#4f46e5">
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:var(--space-md);">
|
|
<div>
|
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Stato (selezione multipla)</label>
|
|
<div class="multiselect-list" id="form-select-statuses"></div>
|
|
</div>
|
|
<div>
|
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Tipo (selezione multipla)</label>
|
|
<div class="multiselect-list" id="form-select-types"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:var(--space-md);">
|
|
<div>
|
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Coda (selezione multipla)</label>
|
|
<div class="multiselect-list" id="form-select-queues"></div>
|
|
</div>
|
|
<div>
|
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Proprietario (selezione multipla)</label>
|
|
<div class="multiselect-list" id="form-select-owners"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="form-label" style="font-weight:500; font-size:0.85rem; margin-bottom:4px; display:block;">Responsabile (selezione multipla)</label>
|
|
<div class="multiselect-list" id="form-select-responsibles"></div>
|
|
</div>
|
|
|
|
<div style="display:flex; justify-content:flex-end; gap:var(--space-sm); border-top:1px solid var(--border-subtle); padding-top:var(--space-md); margin-top:var(--space-xs); flex-shrink:0;">
|
|
<button type="button" class="btn btn-ghost btn-sm" id="btn-series-form-cancel">Annulla</button>
|
|
<button type="submit" class="btn btn-primary btn-sm">Salva Serie</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Draw custom Chart.js lines
|
|
this.updateChartData();
|
|
|
|
// Bind controls listeners
|
|
const periodSelect = document.getElementById('chart-period-preset');
|
|
const customDatesDiv = document.getElementById('chart-custom-dates');
|
|
const dateFromInput = document.getElementById('chart-date-from');
|
|
const dateToInput = document.getElementById('chart-date-to');
|
|
|
|
periodSelect.addEventListener('change', () => {
|
|
if (periodSelect.value === 'custom') {
|
|
customDatesDiv.style.display = 'flex';
|
|
} else {
|
|
customDatesDiv.style.display = 'none';
|
|
this.updateChartData();
|
|
}
|
|
});
|
|
|
|
dateFromInput.addEventListener('change', () => {
|
|
if (dateFromInput.value && dateToInput.value) {
|
|
this.updateChartData();
|
|
}
|
|
});
|
|
|
|
dateToInput.addEventListener('change', () => {
|
|
if (dateFromInput.value && dateToInput.value) {
|
|
this.updateChartData();
|
|
}
|
|
});
|
|
|
|
// Bind Modal listeners
|
|
const modal = document.getElementById('chart-series-modal');
|
|
document.getElementById('configure-series-btn').addEventListener('click', () => {
|
|
modal.style.display = 'flex';
|
|
this.loadModalSeriesList();
|
|
});
|
|
|
|
document.getElementById('export-series-excel-btn').addEventListener('click', () => {
|
|
const periodSelect = document.getElementById('chart-period-preset');
|
|
const dateFromInput = document.getElementById('chart-date-from');
|
|
const dateToInput = document.getElementById('chart-date-to');
|
|
const { start_date, end_date } = getDatesForPeriod(periodSelect.value, dateFromInput.value, dateToInput.value);
|
|
|
|
const visibleSeriesIds = [];
|
|
if (this.chartInstance && this.activeLines) {
|
|
this.activeLines.forEach((line) => {
|
|
const datasetIndex = this.chartInstance.data.datasets.findIndex(ds => ds.label === line.name);
|
|
if (datasetIndex !== -1 && this.chartInstance.isDatasetVisible(datasetIndex)) {
|
|
visibleSeriesIds.push(line.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (visibleSeriesIds.length === 0) {
|
|
Toast.error('Nessuna serie visibile da esportare!');
|
|
return;
|
|
}
|
|
|
|
const idsParam = visibleSeriesIds.join(',');
|
|
window.open(`/api/dashboard/export-excel?start_date=${start_date}&end_date=${end_date}&series_ids=${idsParam}`, '_blank');
|
|
});
|
|
|
|
document.getElementById('chart-series-modal-close').addEventListener('click', () => {
|
|
modal.style.display = 'none';
|
|
this.closeSeriesForm();
|
|
});
|
|
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.style.display = 'none';
|
|
this.closeSeriesForm();
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-add-chart-series').addEventListener('click', () => {
|
|
this.openSeriesForm();
|
|
});
|
|
|
|
document.getElementById('btn-series-form-cancel').addEventListener('click', () => {
|
|
this.closeSeriesForm();
|
|
});
|
|
|
|
document.getElementById('modal-series-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const seriesId = document.getElementById('form-series-id').value;
|
|
const name = document.getElementById('form-series-name').value;
|
|
const color = document.getElementById('form-series-color').value;
|
|
const statuses = this.getSelectedIds('form-select-statuses');
|
|
const types = this.getSelectedIds('form-select-types');
|
|
const queues = this.getSelectedIds('form-select-queues');
|
|
const owners = this.getSelectedIds('form-select-owners');
|
|
const responsibles = this.getSelectedIds('form-select-responsibles');
|
|
|
|
// Preserve current visibility if editing
|
|
let is_visible = 1;
|
|
if (seriesId) {
|
|
const existing = this.activeLines.find(s => s.id === parseInt(seriesId, 10));
|
|
if (existing) is_visible = existing.is_visible;
|
|
}
|
|
|
|
const payload = { name, statuses, types, queues, owners, responsibles, color, is_visible };
|
|
|
|
try {
|
|
if (seriesId) {
|
|
await App.api(`/api/dashboard/chart-lines/${seriesId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
Toast.success('Serie aggiornata!');
|
|
} else {
|
|
await App.api('/api/dashboard/chart-lines', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
Toast.success('Serie creata!');
|
|
}
|
|
this.closeSeriesForm();
|
|
this.loadModalSeriesList();
|
|
this.updateChartData();
|
|
} catch (err) {
|
|
Toast.error('Errore durante il salvataggio: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// Animate bars after render
|
|
requestAnimationFrame(() => {
|
|
document.querySelectorAll('.dist-bar-fill').forEach(bar => {
|
|
const w = bar.style.width;
|
|
bar.style.width = '0%';
|
|
requestAnimationFrame(() => { bar.style.width = w; });
|
|
});
|
|
});
|
|
|
|
// Bind preview limit change event
|
|
const limitSelect = document.getElementById('dashboard-preview-limit');
|
|
if (limitSelect) {
|
|
limitSelect.addEventListener('change', async () => {
|
|
const newLimit = parseInt(limitSelect.value, 10);
|
|
try {
|
|
await App.api('/api/dashboard/settings', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ preview_limit: newLimit }),
|
|
});
|
|
Toast.success(`Limite anteprima aggiornato a ${newLimit} ticket!`);
|
|
this.render();
|
|
} catch (err) {
|
|
Toast.error('Errore durante il salvataggio dell\'impostazione: ' + err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update open ticket count in sidebar badge
|
|
const badge = document.getElementById('open-ticket-count');
|
|
if (badge) {
|
|
badge.textContent = stats.total_open > 0 ? stats.total_open : '';
|
|
}
|
|
|
|
// Update my ticket count in sidebar badge
|
|
const myBadge = document.getElementById('my-ticket-count');
|
|
if (myBadge) {
|
|
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
|
|
}
|
|
|
|
} catch (err) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">⚠️</div>
|
|
<div class="empty-state-text">Errore caricamento dashboard</div>
|
|
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
},
|
|
|
|
async updateChartData() {
|
|
const periodPreset = document.getElementById('chart-period-preset').value;
|
|
const customFrom = document.getElementById('chart-date-from').value;
|
|
const customTo = document.getElementById('chart-date-to').value;
|
|
|
|
// Save selections
|
|
localStorage.setItem('otrs_chart_period', periodPreset);
|
|
if (customFrom) localStorage.setItem('otrs_chart_date_from', customFrom);
|
|
if (customTo) localStorage.setItem('otrs_chart_date_to', customTo);
|
|
|
|
const { start_date, end_date } = getDatesForPeriod(periodPreset, customFrom, customTo);
|
|
|
|
try {
|
|
const data = await App.api(`/api/dashboard/chart-data?start_date=${start_date}&end_date=${end_date}`);
|
|
this.activeLines = data.lines;
|
|
this.drawChart(data.labels, data.lines);
|
|
} catch (err) {
|
|
console.error("Error loading chart data:", err);
|
|
const wrapper = document.querySelector('.chart-canvas-wrapper');
|
|
if (wrapper) {
|
|
wrapper.innerHTML = `
|
|
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-secondary); gap:8px;">
|
|
<span>⚠️ Errore durante il caricamento dei dati del grafico</span>
|
|
<small style="color:var(--text-muted);">${App.escapeHtml(err.message)}</small>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
},
|
|
|
|
drawChart(labels, lines) {
|
|
const canvas = document.getElementById('dashboard-ticket-chart');
|
|
if (!canvas) return;
|
|
|
|
if (this.chartInstance) {
|
|
this.chartInstance.destroy();
|
|
}
|
|
|
|
const isDarkMode = document.body.className.includes('theme-') && !document.body.className.includes('theme-light');
|
|
const textColor = isDarkMode ? '#a6adc8' : '#4b5563';
|
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)';
|
|
|
|
const datasets = lines.map((line) => {
|
|
const color = line.color || '#4f46e5';
|
|
return {
|
|
label: line.name,
|
|
data: line.data,
|
|
borderColor: color,
|
|
backgroundColor: color + '15',
|
|
borderWidth: 2.5,
|
|
tension: 0.3,
|
|
fill: true,
|
|
pointBackgroundColor: color,
|
|
pointBorderColor: '#fff',
|
|
pointHoverRadius: 6,
|
|
pointRadius: 4,
|
|
};
|
|
});
|
|
|
|
this.chartInstance = new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: datasets
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: {
|
|
color: textColor,
|
|
font: {
|
|
family: 'Inter',
|
|
size: 12,
|
|
weight: '500'
|
|
},
|
|
padding: 15,
|
|
}
|
|
},
|
|
tooltip: {
|
|
mode: 'index',
|
|
intersect: false,
|
|
backgroundColor: isDarkMode ? '#1e1e2e' : '#fff',
|
|
titleColor: isDarkMode ? '#cdd6f4' : '#0f172a',
|
|
bodyColor: isDarkMode ? '#a6adc8' : '#475569',
|
|
borderColor: isDarkMode ? '#313244' : '#e2e8f0',
|
|
borderWidth: 1,
|
|
padding: 10,
|
|
titleFont: { family: 'Inter', weight: '600' },
|
|
bodyFont: { family: 'Inter' },
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: {
|
|
color: gridColor,
|
|
drawBorder: false,
|
|
},
|
|
ticks: {
|
|
color: textColor,
|
|
font: { family: 'Inter', size: 10 },
|
|
maxRotation: 45,
|
|
minRotation: 0,
|
|
}
|
|
},
|
|
y: {
|
|
grid: {
|
|
color: gridColor,
|
|
drawBorder: false,
|
|
},
|
|
ticks: {
|
|
color: textColor,
|
|
font: { family: 'Inter', size: 10 },
|
|
stepSize: 1,
|
|
precision: 0,
|
|
},
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
async loadModalSeriesList() {
|
|
const listContainer = document.getElementById('modal-series-list');
|
|
if (!listContainer) return;
|
|
|
|
listContainer.innerHTML = '<div style="text-align:center; padding:10px;"><div class="spinner" style="width:20px;height:20px;"></div></div>';
|
|
|
|
try {
|
|
const lines = await App.api('/api/dashboard/chart-lines');
|
|
this.activeLines = lines;
|
|
|
|
listContainer.innerHTML = lines.map(line => `
|
|
<div class="line-config-row series-config-row" style="display:flex; justify-content:space-between; align-items:center;">
|
|
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
|
<span class="series-color-dot" style="width:14px; height:14px; border-radius:50%; background-color:${line.color || '#4f46e5'}; border:1px solid var(--border-light); display:inline-block;"></span>
|
|
<span class="line-name" style="${!line.is_visible ? 'text-decoration:line-through; color:var(--text-muted);' : ''}">${App.escapeHtml(line.name)} ${line.is_default ? '<small style="color:var(--text-muted);font-weight:normal;">(Predefinita)</small>' : ''}</span>
|
|
</div>
|
|
<div class="line-actions" style="display:flex; align-items:center; gap:6px;">
|
|
<button class="btn btn-ghost btn-sm btn-toggle-visibility" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem;" title="${line.is_visible ? 'Nascondi' : 'Mostra'}">
|
|
${line.is_visible ? '👁️ Nascondi' : '❌ Mostra'}
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm btn-edit-series" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem;">Modifica</button>
|
|
${!line.is_default ? `<button class="btn btn-danger btn-sm btn-delete-series" data-id="${line.id}" style="padding: 2px 6px; font-size:0.75rem; background-color: var(--error); color: white; border: none;">Elimina</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Bind actions
|
|
listContainer.querySelectorAll('.btn-edit-series').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const lineId = parseInt(btn.dataset.id);
|
|
const line = this.activeLines.find(l => l.id === lineId);
|
|
if (line) this.openSeriesForm(line);
|
|
});
|
|
});
|
|
|
|
listContainer.querySelectorAll('.btn-toggle-visibility').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const lineId = parseInt(btn.dataset.id);
|
|
const series = this.activeLines.find(l => l.id === lineId);
|
|
if (series) {
|
|
const nextVisibility = series.is_visible ? 0 : 1;
|
|
try {
|
|
await App.api(`/api/dashboard/chart-lines/${lineId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
name: series.name,
|
|
statuses: series.statuses,
|
|
types: series.types,
|
|
queues: series.queues,
|
|
owners: series.owners,
|
|
responsibles: series.responsibles,
|
|
color: series.color,
|
|
is_visible: nextVisibility
|
|
})
|
|
});
|
|
Toast.success(nextVisibility ? 'Serie attivata!' : 'Serie disattivata!');
|
|
this.loadModalSeriesList();
|
|
this.updateChartData();
|
|
} catch (err) {
|
|
Toast.error('Errore durante l\'aggiornamento dello stato: ' + err.message);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
listContainer.querySelectorAll('.btn-delete-series').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const lineId = parseInt(btn.dataset.id);
|
|
const confirmed = await App.confirm('Elimina serie', 'Sei sicuro di voler eliminare questa serie dal grafico?');
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
await App.api(`/api/dashboard/chart-lines/${lineId}`, { method: 'DELETE' });
|
|
Toast.success('Serie eliminata con successo!');
|
|
this.loadModalSeriesList();
|
|
this.updateChartData();
|
|
} catch (err) {
|
|
Toast.error('Errore durante l\'eliminazione: ' + err.message);
|
|
}
|
|
});
|
|
});
|
|
} catch (err) {
|
|
listContainer.innerHTML = `<div style="color:var(--error); font-size:0.85rem; padding:10px;">Errore: ${App.escapeHtml(err.message)}</div>`;
|
|
}
|
|
},
|
|
|
|
openSeriesForm(line = null) {
|
|
const listSection = document.getElementById('modal-series-list-section');
|
|
const formSection = document.getElementById('modal-series-form');
|
|
|
|
listSection.style.display = 'none';
|
|
formSection.style.display = 'flex';
|
|
|
|
const idInput = document.getElementById('form-series-id');
|
|
const nameInput = document.getElementById('form-series-name');
|
|
const colorInput = document.getElementById('form-series-color');
|
|
|
|
const statuses = line ? line.statuses : [];
|
|
const types = line ? line.types : [];
|
|
const queues = line ? line.queues : [];
|
|
const owners = line ? line.owners : [];
|
|
const responsibles = line ? line.responsibles : [];
|
|
|
|
idInput.value = line ? line.id : '';
|
|
nameInput.value = line ? line.name : '';
|
|
colorInput.value = line ? line.color : '#4f46e5';
|
|
|
|
this.renderMultiselect('form-select-statuses', App.lookups.states || [], statuses);
|
|
this.renderMultiselect('form-select-types', App.lookups.types || [], types);
|
|
this.renderMultiselect('form-select-queues', App.lookups.queues || [], queues);
|
|
this.renderMultiselect('form-select-owners', App.lookups.users || [], owners, 'user');
|
|
this.renderMultiselect('form-select-responsibles', App.lookups.users || [], responsibles, 'user');
|
|
},
|
|
|
|
closeSeriesForm() {
|
|
const listSection = document.getElementById('modal-series-list-section');
|
|
const formSection = document.getElementById('modal-series-form');
|
|
if (listSection && formSection) {
|
|
listSection.style.display = 'block';
|
|
formSection.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
renderMultiselect(containerId, items, selectedIds, type = 'normal') {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
|
|
const sorted = [...items].sort((a, b) => {
|
|
const nameA = type === 'user' ? `${a.last_name || ''} ${a.first_name || ''}` : (a.name || '');
|
|
const nameB = type === 'user' ? `${b.last_name || ''} ${b.first_name || ''}` : (b.name || '');
|
|
return nameA.localeCompare(nameB, 'it', { sensitivity: 'base' });
|
|
});
|
|
|
|
container.innerHTML = sorted.map(item => {
|
|
const id = item.id;
|
|
const isSelected = selectedIds.includes(id) || selectedIds.includes(String(id)) || selectedIds.includes(parseInt(id));
|
|
let label = item.name;
|
|
if (type === 'user') {
|
|
label = `${item.first_name || ''} ${item.last_name || ''} (${item.login || ''})`;
|
|
}
|
|
return `<div class="multiselect-item ${isSelected ? 'selected' : ''}" data-id="${id}">${App.escapeHtml(label)}</div>`;
|
|
}).join('');
|
|
|
|
// Bind item click
|
|
container.querySelectorAll('.multiselect-item').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
el.classList.toggle('selected');
|
|
});
|
|
});
|
|
},
|
|
|
|
getSelectedIds(containerId) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return [];
|
|
return Array.from(container.querySelectorAll('.multiselect-item.selected')).map(el => {
|
|
const id = el.dataset.id;
|
|
return isNaN(parseInt(id)) ? id : parseInt(id);
|
|
});
|
|
}
|
|
};
|