Files
otrs-turbo/public/js/views/timeAccounting.js
T

1211 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TimeAccountingView — Storico consuntivazione
* Shows a summary of notes/emails entered by the selected agent on the selected day,
* with options on the right side to edit consumed time or backdate the article.
* Includes interactive day pills strip, configurable WORK_DAYS, and show/hide non-working days toggle.
*/
const TimeAccountingView = {
selectedAgentId: null,
selectedDate: null,
selectedArticleId: null,
summaryData: null,
viewMode: 'list', // 'list' | 'calendar'
showAllTime: false,
showNonWorkingDays: localStorage.getItem('otrs_ta_show_non_working_days') !== 'false',
formatLocalISO(d) {
if (!d) return '';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
},
async render() {
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento consuntivazione...</p></div>';
try {
await App.ensureLookups();
if (!this.selectedAgentId) {
this.selectedAgentId = App.currentAgentId;
}
if (!this.selectedDate) {
this.selectedDate = this.formatLocalISO(new Date());
}
await this.loadData();
} catch (err) {
container.innerHTML = `<div class="empty-state"><p style="color:var(--danger)">Errore caricamento: ${App.escapeHtml(err.message)}</p></div>`;
}
},
async loadData() {
const container = document.getElementById('view-container');
const params = new URLSearchParams({
agent_id: this.selectedAgentId,
date: this.selectedDate,
});
if (this.showAllTime) {
params.set('range', 'all');
} else if (this.viewMode === 'calendar') {
params.set('range', 'month');
}
let data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
// If non-working days are hidden, ensure selectedDate is a valid working day
if (!this.showNonWorkingDays) {
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
let shifted = false;
while (workDays.length > 0 && !workDays.includes(d.getDay())) {
d.setDate(d.getDate() - 1);
shifted = true;
}
if (shifted) {
this.selectedDate = this.formatLocalISO(d);
params.set('date', this.selectedDate);
data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
}
}
this.summaryData = data;
// Auto-select first article if current selection is invalid
const articles = data.articles || [];
if (!articles.find(a => String(a.article_id) === String(this.selectedArticleId))) {
this.selectedArticleId = articles.length > 0 ? articles[0].article_id : null;
}
const currentScrollX = window.scrollX;
const currentScrollY = window.scrollY;
container.innerHTML = this.buildHtml(data);
this.bindEvents();
window.scrollTo(currentScrollX, currentScrollY);
// Smooth scroll active day pill into center view
setTimeout(() => {
const activePill = container.querySelector('.ta-day-pill.active');
if (activePill) {
activePill.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
}, 50);
},
formatPillTime(mins) {
if (!mins || mins <= 0) return '0m';
const h = Math.floor(mins / 60);
const m = Math.round(mins % 60);
if (h > 0 && m > 0) return `${h}h ${m}m`;
if (h > 0) return `${h}h`;
return `${m}m`;
},
buildHtml(data) {
const articles = data.articles || [];
const totalMins = Math.round(data.total_minutes || 0);
const targetMins = data.target_minutes || 480;
const workDays = data.work_days || [1, 2, 3, 4, 5];
const hours = Math.floor(totalMins / 60);
const mins = totalMins % 60;
const formattedTotal = hours > 0 ? `${hours}h ${mins}m (${totalMins} min)` : `${totalMins} min`;
const selectedParts = this.selectedDate.split('-').map(Number);
const selectedDateObj = new Date(selectedParts[0], selectedParts[1] - 1, selectedParts[2]);
const dayOfWeek = selectedDateObj.getDay();
const isWorkDay = workDays.includes(dayOfWeek);
const isTargetReached = totalMins >= targetMins;
let headerBadgeBg = 'var(--accent-primary)';
let headerBadgeText = `Totale: ${formattedTotal}`;
if (isWorkDay) {
if (isTargetReached) {
headerBadgeBg = '#15803d';
headerBadgeText = `Totale: ${formattedTotal} — ✓ Soglia raggiunta`;
} else {
const diff = targetMins - totalMins;
const diffH = Math.floor(diff / 60);
const diffM = Math.round(diff % 60);
const diffStr = diffH > 0 ? `${diffH}h ${diffM}m` : `${diffM}m`;
headerBadgeBg = '#b91c1c';
headerBadgeText = `Totale: ${formattedTotal} — Mancano ${diffStr}`;
}
}
const users = App.lookups.users || [];
const selectedArticle = articles.find(a => String(a.article_id) === String(this.selectedArticleId));
return `
<div class="view-header" style="padding:var(--space-md) var(--space-xl); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-sm); border-bottom:1px solid var(--border-subtle);">
<div style="display:flex; align-items:center; gap:var(--space-sm);">
<button id="ta-toggle-view-mode" class="btn btn-ghost btn-sm" style="padding:4px 8px; display:inline-flex; align-items:center; justify-content:center; border-radius:6px; background:var(--bg-tertiary); color:var(--accent-primary); border:1px solid var(--border-subtle); cursor:pointer;" title="${this.viewMode === 'list' ? 'Passa alla vista Calendario Mese' : 'Passa alla vista Dettaglio Elenco'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px; height:20px;">
${this.viewMode === 'calendar'
? '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>'
: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>'}
</svg>
</button>
<span style="font-weight:600; font-size:1rem;">Storico consuntivazione</span>
<span class="badge" style="background:${headerBadgeBg}; color:#fff; font-size:0.78rem; padding:3px 10px; border-radius:12px;">
${headerBadgeText}
</span>
</div>
</div>
<!-- Filters Bar -->
<div style="display:flex; flex-wrap:wrap; gap:var(--space-md); padding:var(--space-md) var(--space-xl); border-bottom:1px solid var(--border-subtle); background:var(--bg-secondary); align-items:flex-end;">
<div style="display:flex; flex-direction:column; gap:4px;">
<label style="font-size:0.75rem; color:var(--text-muted); font-weight:500;">Agente</label>
<select id="ta-agent-select" class="form-select" style="min-width:180px; height:36px; font-size:0.85rem; padding:6px 28px 6px 10px;">
${users.map(u => `<option value="${u.id}" ${String(u.id) === String(this.selectedAgentId) ? 'selected' : ''}>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</option>`).join('')}
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px;">
<label style="font-size:0.75rem; color:var(--text-muted); font-weight:500;">Data (Calendario)</label>
<input type="date" id="ta-date-input" class="form-input" value="${this.selectedDate}" style="height:36px; font-size:0.85rem; padding:6px 10px;" />
</div>
<div style="display:flex; gap:var(--space-xs); align-self:flex-end;">
<button id="ta-btn-today" class="btn btn-ghost btn-sm" style="height:36px;">Oggi</button>
<button id="ta-btn-toggle-nonwork" class="btn btn-ghost btn-sm" style="height:36px; display:flex; align-items:center; gap:6px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px; height:15px;">
${this.showNonWorkingDays
? '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>'
: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'}
</svg>
<span>${this.showNonWorkingDays ? 'Nascondi non lavorativi' : 'Mostra non lavorativi'}</span>
</button>
</div>
</div>
${this.viewMode === 'calendar' ? this.buildCalendarGrid(data) : `
<!-- Interactive Day Pills Strip -->
${this.buildDayStrip(data)}
<!-- Two-column Master/Detail Layout -->
<div style="display:flex; gap:var(--space-md); padding:var(--space-md) var(--space-xl); align-items:flex-start; min-height:calc(100vh - 280px);">
<!-- Left Side: List of Articles -->
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-sm);">
${articles.length === 0 ? `
<div class="empty-state" style="padding:var(--space-2xl); text-align:center; color:var(--text-muted); background:var(--bg-card); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px; height:48px; margin:0 auto var(--space-md); display:block; opacity:0.4;">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<p style="font-size:0.9rem; font-weight:500;">Nessuna nota o email trovata in questa data per l'agente selezionato.</p>
</div>
` : articles.map(a => this.buildArticleCard(a)).join('')}
</div>
<!-- Right Side: Details & Actions Panel -->
<div style="width:380px; flex-shrink:0; position:sticky; top:20px;">
${selectedArticle ? this.buildDetailPanel(selectedArticle) : `
<div class="card" style="padding:var(--space-lg); text-align:center; color:var(--text-muted); font-size:0.85rem;">
Seleziona una nota o email a sinistra per gestirne il tempo o retrodatare.
</div>
`}
</div>
</div>
`}
`;
},
buildDayStrip(data) {
const selectedParts = this.selectedDate.split('-').map(Number);
const selected = new Date(selectedParts[0], selectedParts[1] - 1, selectedParts[2]);
const todayStr = this.formatLocalISO(new Date());
const weekdays = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
const months = ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'];
const dailyTotals = (data && data.daily_totals) || {};
const targetMins = (data && data.target_minutes) || 480;
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
// Generate 2 full weeks of days (15 calendar days if showing all, or 10-11 working days if hiding non-working days)
const dateList = [];
if (this.showNonWorkingDays) {
for (let offset = -7; offset <= 7; offset++) {
const d = new Date(selected);
d.setDate(d.getDate() + offset);
dateList.push(d);
}
} else {
const before = [];
let dPrev = new Date(selected);
while (before.length < 5) {
dPrev.setDate(dPrev.getDate() - 1);
if (workDays.includes(dPrev.getDay())) {
before.unshift(new Date(dPrev));
}
}
const current = new Date(selected);
const after = [];
let dNext = new Date(selected);
while (after.length < 5) {
dNext.setDate(dNext.getDate() + 1);
if (workDays.includes(dNext.getDay())) {
after.push(new Date(dNext));
}
}
dateList.push(...before, current, ...after);
}
const days = [];
for (const d of dateList) {
const isoStr = this.formatLocalISO(d);
const dayOfWeek = d.getDay(); // 0 = Sun, 1 = Mon ... 6 = Sat
const isWorkDay = workDays.includes(dayOfWeek);
const isSelected = isoStr === this.selectedDate;
const isToday = isoStr === todayStr;
const dayNum = d.getDate();
const weekdayName = weekdays[dayOfWeek];
const monthName = months[d.getMonth()];
const dayMins = Math.round(dailyTotals[isoStr] || 0);
const formattedTime = this.formatPillTime(dayMins);
const isTargetReached = dayMins >= targetMins;
// Color coding computation
let bgStyle = 'var(--bg-card)';
let borderStyle = '1px solid var(--border-subtle)';
let textColor = 'var(--text-primary)';
let badgeBg = 'var(--bg-tertiary)';
let badgeColor = 'var(--text-muted)';
if (!isWorkDay) {
// Non-working days (e.g., weekends)
if (isSelected) {
bgStyle = 'var(--accent-primary)';
borderStyle = '1px solid var(--accent-primary)';
textColor = '#ffffff';
badgeBg = 'rgba(255,255,255,0.25)';
badgeColor = '#ffffff';
} else if (isToday) {
bgStyle = 'rgba(79, 70, 229, 0.12)';
borderStyle = '1px solid var(--border-subtle)';
textColor = 'var(--accent-primary)';
}
} else {
// Working days (configured in WORK_DAYS)
if (isTargetReached) {
// Verdino (green)
if (isSelected) {
bgStyle = '#15803d';
borderStyle = '1px solid #166534';
textColor = '#ffffff';
badgeBg = 'rgba(255,255,255,0.28)';
badgeColor = '#ffffff';
} else {
bgStyle = 'rgba(34, 197, 94, 0.16)';
borderStyle = '1px solid rgba(34, 197, 94, 0.5)';
textColor = '#15803d';
badgeBg = 'rgba(34, 197, 94, 0.25)';
badgeColor = '#15803d';
}
} else {
// Rosso (red)
if (isSelected) {
bgStyle = '#b91c1c';
borderStyle = '1px solid #991b1b';
textColor = '#ffffff';
badgeBg = 'rgba(255,255,255,0.28)';
badgeColor = '#ffffff';
} else {
bgStyle = 'rgba(239, 68, 68, 0.14)';
borderStyle = '1px solid rgba(239, 68, 68, 0.4)';
textColor = '#b91c1c';
badgeBg = 'rgba(239, 68, 68, 0.22)';
badgeColor = '#b91c1c';
}
}
}
days.push(`
<button type="button" class="ta-day-pill ${isSelected ? 'active' : ''}" data-date="${isoStr}"
style="display:flex; flex-direction:column; align-items:center; justify-content:center; padding:5px 10px; min-width:64px; height:64px; border-radius:var(--radius-md); border:${borderStyle}; background:${bgStyle}; color:${textColor}; cursor:pointer; transition:all 0.15s ease; flex-shrink:0; font-family:inherit;">
<span style="font-size:0.65rem; font-weight:700; text-transform:uppercase; opacity:${isSelected ? '0.95' : '0.75'};">${weekdayName} ${dayNum}</span>
<span class="badge" style="font-size:0.68rem; font-weight:800; padding:2px 6px; border-radius:8px; margin:2px 0; background:${badgeBg}; color:${badgeColor};">
⏱️ ${formattedTime}
</span>
<span style="font-size:0.60rem; opacity:${isSelected ? '0.85' : '0.65'};">${monthName}${isToday ? ' (Oggi)' : ''}</span>
</button>
`);
}
return `
<div style="display:flex; align-items:center; gap:8px; padding:var(--space-xs) var(--space-xl); border-bottom:1px solid var(--border-subtle); background:var(--bg-secondary);">
<button id="ta-day-prev" class="btn btn-ghost btn-sm" style="padding:6px 10px; font-weight:bold; font-size:1rem; height:64px;" title="Giorno precedente">‹</button>
<div id="ta-days-container" style="display:flex; align-items:center; gap:6px; flex:1; overflow-x:auto; padding:2px 0; scrollbar-width:thin;">
${days.join('')}
</div>
<button id="ta-day-next" class="btn btn-ghost btn-sm" style="padding:6px 10px; font-weight:bold; font-size:1rem; height:64px;" title="Giorno successivo">›</button>
</div>
`;
},
buildCalendarGrid(data) {
const todayStr = this.formatLocalISO(new Date());
const dailyTotals = (data && data.daily_totals) || {};
const targetMins = (data && data.target_minutes) || 480;
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
const monthNames = ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'];
const headerDays = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
// If showAllTime is true, render all months present in dailyTotals or at least current month down to earliest month
let monthsToRender = [];
if (this.showAllTime) {
const dates = Object.keys(dailyTotals).sort();
let startYear, startMonth;
if (dates.length > 0) {
const [y, m] = dates[0].split('-').map(Number);
startYear = y;
startMonth = m - 1;
} else {
const now = new Date();
startYear = now.getFullYear();
startMonth = now.getMonth();
}
const now = new Date();
const endYear = now.getFullYear();
const endMonth = now.getMonth();
// Loop month by month from earliest to current/selected
let curr = new Date(startYear, startMonth, 1);
const end = new Date(endYear, endMonth, 1);
while (curr <= end) {
monthsToRender.push({ year: curr.getFullYear(), month: curr.getMonth() });
curr.setMonth(curr.getMonth() + 1);
}
// Reverse so newest months appear first
monthsToRender.reverse();
} else {
const selectedParts = this.selectedDate.split('-').map(Number);
monthsToRender.push({ year: selectedParts[0], month: selectedParts[1] - 1 });
}
const renderSingleMonthGrid = (mYear, mMonth) => {
const firstDay = new Date(mYear, mMonth, 1);
const lastDay = new Date(mYear, mMonth + 1, 0);
let startDayOfWeek = firstDay.getDay() - 1;
if (startDayOfWeek === -1) startDayOfWeek = 6;
const cells = [];
for (let i = 0; i < startDayOfWeek; i++) {
cells.push(`<div style="background:var(--bg-tertiary); opacity:0.3; border-radius:var(--radius-md); min-height:85px; border:1px dashed var(--border-subtle);"></div>`);
}
const daysInMonth = lastDay.getDate();
for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) {
const d = new Date(mYear, mMonth, dayNum);
const isoStr = this.formatLocalISO(d);
const dayOfWeek = d.getDay();
const isWorkDay = workDays.includes(dayOfWeek);
const isSelected = isoStr === this.selectedDate;
const isToday = isoStr === todayStr;
const dayMins = Math.round(dailyTotals[isoStr] || 0);
const formattedTime = this.formatPillTime(dayMins);
const isTargetReached = dayMins >= targetMins;
let bgStyle = 'var(--bg-card)';
let borderStyle = '1px solid var(--border-subtle)';
let textColor = 'var(--text-primary)';
let badgeBg = 'var(--bg-tertiary)';
let badgeColor = 'var(--text-muted)';
if (!isWorkDay) {
if (isSelected) {
bgStyle = 'var(--accent-primary)';
borderStyle = '2px solid var(--accent-primary)';
textColor = '#ffffff';
badgeBg = 'rgba(255,255,255,0.25)';
badgeColor = '#ffffff';
} else if (isToday) {
bgStyle = 'rgba(79, 70, 229, 0.12)';
textColor = 'var(--accent-primary)';
}
} else {
if (isTargetReached) {
if (isSelected) {
bgStyle = '#15803d';
borderStyle = '2px solid #166534';
textColor = '#ffffff';
badgeBg = 'rgba(255,255,255,0.28)';
badgeColor = '#ffffff';
} else {
bgStyle = 'rgba(34, 197, 94, 0.16)';
borderStyle = '1px solid rgba(34, 197, 94, 0.5)';
textColor = '#15803d';
badgeBg = 'rgba(34, 197, 94, 0.25)';
badgeColor = '#15803d';
}
} else {
if (isSelected) {
bgStyle = '#b91c1c';
borderStyle = '2px solid #991b1b';
textColor = '#ffffff';
badgeBg = 'rgba(255,255,255,0.28)';
badgeColor = '#ffffff';
} else {
bgStyle = 'rgba(239, 68, 68, 0.14)';
borderStyle = '1px solid rgba(239, 68, 68, 0.4)';
textColor = '#b91c1c';
badgeBg = 'rgba(239, 68, 68, 0.22)';
badgeColor = '#b91c1c';
}
}
}
const weekdays = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
const weekdayName = weekdays[dayOfWeek];
cells.push(`
<button type="button" class="ta-cal-day-cell ta-day-pill ${isSelected ? 'active' : ''}" data-date="${isoStr}"
style="display:flex; flex-direction:column; justify-content:space-between; align-items:flex-start; padding:8px 10px; min-height:85px; border-radius:var(--radius-md); border:${borderStyle}; background:${bgStyle}; color:${textColor}; cursor:pointer; transition:all 0.15s ease; width:100%; font-family:inherit; text-align:left;">
<div style="display:flex; justify-content:space-between; width:100%; align-items:center;">
<div style="display:flex; align-items:baseline; gap:4px;">
<span style="font-size:1.1rem; font-weight:800; color:inherit;">${dayNum}</span>
<span style="font-size:0.7rem; font-weight:600; text-transform:uppercase; opacity:0.8; color:inherit;">${weekdayName}</span>
</div>
${isToday ? `<span style="font-size:0.62rem; font-weight:700; background:var(--accent-primary); color:#fff; padding:1px 5px; border-radius:4px;">OGGI</span>` : ''}
</div>
<div style="margin-top:auto; width:100%; display:flex; flex-direction:column; gap:2px;">
<span class="badge" style="font-size:0.75rem; font-weight:800; padding:3px 6px; border-radius:6px; background:${badgeBg}; color:${badgeColor}; display:block; text-align:center; width:100%;">
⏱️ ${formattedTime}
</span>
</div>
</button>
`);
}
const monthTitle = `${monthNames[mMonth]} ${mYear}`;
return `
<div class="ta-month-section" style="margin-bottom:var(--space-xl);">
<div style="display:flex; align-items:center; gap:var(--space-md); margin-bottom:var(--space-md);">
<h3 style="margin:0; font-size:1.15rem; font-weight:800; color:var(--text-primary); text-transform:capitalize; white-space:nowrap;">
📅 ${monthTitle}
</h3>
<div style="flex:1; height:2px; background:linear-gradient(to right, var(--border-subtle), transparent);"></div>
</div>
<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:8px; margin-bottom:8px; text-align:center; font-weight:700; font-size:0.8rem; color:var(--text-muted);">
${headerDays.map(h => `<div>${h}</div>`).join('')}
</div>
<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:8px;">
${cells.join('')}
</div>
</div>
`;
};
const selectedParts = this.selectedDate.split('-').map(Number);
const selectedYear = selectedParts[0];
const selectedMonth = selectedParts[1] - 1;
const currentMonthName = `${monthNames[selectedMonth]} ${selectedYear}`;
return `
<div style="padding:var(--space-md) var(--space-xl);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-md); background:var(--bg-secondary); padding:var(--space-sm) var(--space-md); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
<div style="display:flex; align-items:center; gap:var(--space-xs);">
<button id="ta-cal-toggle-all-time" class="btn btn-ghost btn-sm" style="font-size:0.82rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:${this.showAllTime ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; color:${this.showAllTime ? '#ffffff' : 'var(--text-secondary)'}; border:1px solid var(--border-subtle);" title="Carica l'intero storico delle attività fin dalla prima consuntivazione registrata">
🚀 ${this.showAllTime ? 'Tutto lo Storico (Attivo)' : 'Carica Tutto lo Storico'}
</button>
<button id="ta-cal-show-chart" class="btn btn-ghost btn-sm" style="font-size:0.82rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border-subtle);" title="Apri grafico con l'andamento della consuntivazione">
📊 Mostra Grafico
</button>
<button id="ta-cal-month-prev" class="btn btn-ghost btn-sm" style="font-size:0.9rem; font-weight:600;" ${this.showAllTime ? 'disabled style="opacity:0.4; cursor:not-allowed;"' : ''}>‹ Mese Precedente</button>
</div>
<span style="font-weight:700; font-size:1.1rem; color:var(--text-primary);">${this.showAllTime ? 'Tutto lo Storico Consuntivato' : currentMonthName}</span>
<button id="ta-cal-month-next" class="btn btn-ghost btn-sm" style="font-size:0.9rem; font-weight:600;" ${this.showAllTime ? 'disabled style="opacity:0.4; cursor:not-allowed;"' : ''}>Mese Successivo ›</button>
</div>
${monthsToRender.map(m => renderSingleMonthGrid(m.year, m.month)).join('')}
</div>
`;
},
buildArticleCard(a) {
const isSelected = String(a.article_id) === String(this.selectedArticleId);
const dt = a.create_time ? new Date(a.create_time).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : '—';
const bodyPreview = App.escapeHtml((a.a_body || '').replace(/<[^>]*>/g, ' ').substring(0, 150)) + ((a.a_body || '').length > 150 ? '...' : '');
const timeMins = parseFloat(a.time_unit || 0);
return `
<div class="card ta-article-card ${isSelected ? 'selected' : ''}" data-article-id="${a.article_id}" style="cursor:pointer; padding:var(--space-md); border:1px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-subtle)'}; background:${isSelected ? 'var(--bg-secondary)' : 'var(--bg-card)'}; transition:all 0.15s ease;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<div style="display:flex; align-items:center; gap:6px;">
<a href="#/tickets/${a.ticket_id}" class="ticket-tn-link" onclick="event.stopPropagation();" style="font-weight:700;">#${App.escapeHtml(a.ticket_tn || a.ticket_id)}</a>
<span style="font-size:0.82rem; font-weight:600; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:250px;">${App.escapeHtml(a.ticket_title || '')}</span>
</div>
<span style="font-size:0.75rem; color:var(--text-muted);">${dt}</span>
</div>
<div style="font-size:0.82rem; font-weight:600; color:var(--accent-primary); margin-bottom:4px;">
${App.escapeHtml(a.a_subject || '(senza oggetto)')}
</div>
<div style="font-size:0.78rem; color:var(--text-secondary); line-height:1.4; margin-bottom:8px;">
${bodyPreview || '<em>Nessun testo</em>'}
</div>
<div style="display:flex; justify-content:space-between; align-items:center; font-size:0.75rem;">
<span class="badge" style="background:var(--bg-tertiary); color:var(--text-secondary);">${App.escapeHtml(a.channel || a.sender_type || 'Nota')}</span>
<span class="badge" style="background:${timeMins > 0 ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'}; color:${timeMins > 0 ? '#16a34a' : 'var(--text-muted)'}; font-weight:600;">
⏱️ ${timeMins} min
</span>
</div>
</div>
`;
},
buildDetailPanel(a) {
const timeMins = parseFloat(a.time_unit || 0);
const localISO = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
return `
<div class="card" style="padding:var(--space-md); border:1px solid var(--border-subtle); display:flex; flex-direction:column; gap:var(--space-md);">
<div style="border-bottom:1px solid var(--border-subtle); padding-bottom:var(--space-xs);">
<div style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; font-weight:700;">Ticket Collegato</div>
<a href="#/tickets/${a.ticket_id}" class="ticket-tn-link" style="font-size:0.95rem; font-weight:700; display:block; margin-top:2px;">
#${App.escapeHtml(a.ticket_tn || a.ticket_id)} — ${App.escapeHtml(a.ticket_title || '')}
</a>
</div>
<div>
<div style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; font-weight:700;">Oggetto Nota/Mail</div>
<div style="font-size:0.88rem; font-weight:600; color:var(--text-primary); margin-top:2px;">
${App.escapeHtml(a.a_subject || '(senza oggetto)')}
</div>
</div>
<div>
<div style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; font-weight:700; margin-bottom:4px;">Contenuto</div>
<div style="max-height:180px; overflow-y:auto; padding:var(--space-xs) var(--space-sm); background:var(--bg-tertiary); border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-size:0.78rem; color:var(--text-primary); line-height:1.4;">
${App.escapeHtml(a.a_body || '')}
</div>
</div>
<hr style="border:0; border-top:1px solid var(--border-subtle); margin:0;" />
<!-- Form Modifica Tempo -->
<div style="background:var(--bg-secondary); padding:var(--space-sm); border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<label style="font-size:0.78rem; font-weight:700; color:var(--text-primary); display:block; margin-bottom:6px;">
⏱️ Modifica Tempo Consuntivato (minuti)
</label>
<div style="display:flex; gap:var(--space-xs);">
<input type="number" step="any" min="0" id="ta-input-time" class="form-input" value="${timeMins}" style="flex:1; height:32px; font-size:0.85rem;" />
<button id="ta-btn-save-time" class="btn btn-primary btn-sm" style="height:32px;">Salva Tempo</button>
</div>
</div>
<!-- Form Retrodatazione -->
<div style="background:var(--bg-secondary); padding:var(--space-sm); border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<label style="font-size:0.78rem; font-weight:700; color:var(--text-primary); display:block; margin-bottom:6px;">
📅 Retrodata data/ora articolo
</label>
<div style="display:flex; flex-direction:column; gap:6px;">
<input type="datetime-local" id="ta-input-date" class="form-input" value="${localISO(a.create_time)}" style="height:32px; font-size:0.82rem;" />
<button id="ta-btn-save-date" class="btn btn-primary btn-sm" style="height:32px; align-self:flex-end;">Salva Data</button>
</div>
</div>
</div>
`;
},
bindEvents() {
// Toggle View Mode (List <-> Calendar Grid)
document.getElementById('ta-toggle-view-mode')?.addEventListener('click', () => {
this.viewMode = this.viewMode === 'list' ? 'calendar' : 'list';
this.loadData();
});
// Toggle Show All Time history
document.getElementById('ta-cal-toggle-all-time')?.addEventListener('click', () => {
this.showAllTime = !this.showAllTime;
Toast.info(this.showAllTime ? 'Caricamento dell\'intero storico in corso...' : 'Filtro intervallo standard ripristinato');
this.loadData();
});
// Calendar month navigation
document.getElementById('ta-cal-month-prev')?.addEventListener('click', () => {
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 2, 1);
this.selectedDate = this.formatLocalISO(d);
this.loadData();
});
document.getElementById('ta-cal-month-next')?.addEventListener('click', () => {
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1], 1);
this.selectedDate = this.formatLocalISO(d);
this.loadData();
});
// Agent change
document.getElementById('ta-agent-select')?.addEventListener('change', (e) => {
this.selectedAgentId = e.target.value;
this.loadData();
});
// Date change (Calendar picker)
document.getElementById('ta-date-input')?.addEventListener('change', (e) => {
this.selectedDate = e.target.value;
this.loadData();
});
// Today button
document.getElementById('ta-btn-today')?.addEventListener('click', () => {
this.selectedDate = this.formatLocalISO(new Date());
this.loadData();
});
// Toggle non-working days button
document.getElementById('ta-btn-toggle-nonwork')?.addEventListener('click', () => {
this.showNonWorkingDays = !this.showNonWorkingDays;
localStorage.setItem('otrs_ta_show_non_working_days', this.showNonWorkingDays);
this.loadData();
});
// Day pill clicks (also handles calendar cell clicks)
document.querySelectorAll('.ta-day-pill[data-date]').forEach(pill => {
pill.addEventListener('click', () => {
const dateClicked = pill.dataset.date;
if (this.viewMode === 'calendar') {
this.selectedDate = dateClicked;
this.viewMode = 'list';
} else {
this.selectedDate = dateClicked;
}
this.loadData();
});
});
// Previous day button
document.getElementById('ta-day-prev')?.addEventListener('click', () => {
const workDays = (this.summaryData && this.summaryData.work_days) || [1, 2, 3, 4, 5];
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
do {
d.setDate(d.getDate() - 1);
} while (!this.showNonWorkingDays && workDays.length > 0 && !workDays.includes(d.getDay()));
this.selectedDate = this.formatLocalISO(d);
this.loadData();
});
// Next day button
document.getElementById('ta-day-next')?.addEventListener('click', () => {
const workDays = (this.summaryData && this.summaryData.work_days) || [1, 2, 3, 4, 5];
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
do {
d.setDate(d.getDate() + 1);
} while (!this.showNonWorkingDays && workDays.length > 0 && !workDays.includes(d.getDay()));
this.selectedDate = this.formatLocalISO(d);
this.loadData();
});
// Card selection click
document.querySelectorAll('.ta-article-card').forEach(card => {
card.addEventListener('click', () => {
this.selectedArticleId = card.dataset.articleId;
const currentScrollX = window.scrollX;
const currentScrollY = window.scrollY;
const container = document.getElementById('view-container');
container.innerHTML = this.buildHtml(this.summaryData);
this.bindEvents();
window.scrollTo(currentScrollX, currentScrollY);
});
});
// Save time action
const saveTimeBtn = document.getElementById('ta-btn-save-time');
if (saveTimeBtn) {
saveTimeBtn.addEventListener('click', async () => {
const timeInput = document.getElementById('ta-input-time');
const timeVal = parseFloat(timeInput.value);
if (isNaN(timeVal) || timeVal < 0) {
Toast.warning('Inserisci un tempo valido in minuti');
return;
}
try {
saveTimeBtn.disabled = true;
await App.api(`/api/tickets/articles/${this.selectedArticleId}/time`, {
method: 'PUT',
body: JSON.stringify({ time_unit: timeVal }),
});
Toast.success('Tempo consuntivato aggiornato!');
this.loadData();
} catch (err) {
Toast.error('Errore aggiornamento tempo: ' + err.message);
saveTimeBtn.disabled = false;
}
});
}
// Save retrodate action
const saveDateBtn = document.getElementById('ta-btn-save-date');
if (saveDateBtn) {
saveDateBtn.addEventListener('click', async () => {
const dateInput = document.getElementById('ta-input-date');
const dateVal = dateInput.value;
if (!dateVal) {
Toast.warning('Scegli una data/ora valida');
return;
}
try {
saveDateBtn.disabled = true;
await App.api(`/api/tickets/articles/${this.selectedArticleId}/retrodata-article`, {
method: 'POST',
body: JSON.stringify({ create_time: dateVal }),
});
Toast.success('Articolo retrodatato con successo!');
this.loadData();
} catch (err) {
Toast.error('Errore retrodatazione: ' + err.message);
saveDateBtn.disabled = false;
}
});
}
// Show Chart Modal action
document.getElementById('ta-cal-show-chart')?.addEventListener('click', () => {
this.openChartModal();
});
},
async openChartModal() {
const existingModal = document.getElementById('ta-chart-modal');
if (existingModal) existingModal.remove();
const users = App.lookups.users || [];
// Saved banned agent IDs (semitrasparenti e bloccati)
let bannedAgentIds = [];
try {
const savedBanned = localStorage.getItem('otrs_ta_chart_banned_agents');
if (savedBanned) bannedAgentIds = JSON.parse(savedBanned);
} catch (_) {}
if (!Array.isArray(bannedAgentIds)) bannedAgentIds = [];
// Saved selected agent IDs or default to current agent
let activeAgentIds = [];
try {
const saved = localStorage.getItem('otrs_ta_chart_agents');
if (saved) activeAgentIds = JSON.parse(saved);
} catch (_) {}
if (!Array.isArray(activeAgentIds) || activeAgentIds.length === 0) {
activeAgentIds = [String(this.selectedAgentId || App.currentAgentId)];
}
// Filter activeAgentIds to remove any banned agents
activeAgentIds = activeAgentIds.filter(id => !bannedAgentIds.includes(id));
// Saved range or default to 'all'
let selectedRange = localStorage.getItem('otrs_ta_chart_range') || 'all';
const modal = document.createElement('div');
modal.id = 'ta-chart-modal';
modal.className = 'modal';
modal.style.cssText = 'display:flex; position:fixed; z-index:10000; inset:0; background:rgba(0,0,0,0.65); align-items:center; justify-content:center; padding:20px; backdrop-filter:blur(4px);';
const isDarkMode = document.body.classList.contains('theme-dark');
const textColor = isDarkMode ? '#cbd5e1' : '#334155';
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)';
// Paletta di colori vivaci per gli agenti nel grafico
const colorPalette = [
'#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899',
'#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#a855f7'
];
modal.innerHTML = `
<div class="modal-content" style="max-width:1100px; width:94vw; max-height:90vh; background:var(--bg-card); border-radius:var(--radius-lg); border:1px solid var(--border-subtle); padding:var(--space-xl); box-shadow:0 20px 50px rgba(0,0,0,0.5); display:flex; flex-direction:column; gap:var(--space-md); overflow:hidden;">
<!-- Header -->
<div style="display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); padding-bottom:var(--space-md); flex-wrap:wrap; gap:12px;">
<div style="display:flex; align-items:center; gap:var(--space-sm);">
<span style="font-size:1.4rem;">📊</span>
<div>
<span style="font-weight:700; font-size:1.15rem; color:var(--text-primary); display:block;">Andamento Consuntivazione Tempo</span>
<span style="font-size:0.78rem; color:var(--text-muted);">Confronto e analisi temporale delle consuntivazioni agenti</span>
</div>
</div>
<!-- Select Period Filter -->
<div style="display:flex; align-items:center; gap:10px;">
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Periodo:</label>
<select id="ta-chart-range-select" class="form-select" style="height:34px; font-size:0.82rem; padding:0 28px 0 10px; background:var(--bg-secondary);">
<option value="all" ${selectedRange === 'all' ? 'selected' : ''}>🚀 Tutto lo Storico</option>
<option value="7" ${selectedRange === '7' ? 'selected' : ''}>Ultimi 7 Giorni</option>
<option value="14" ${selectedRange === '14' ? 'selected' : ''}>Ultimi 14 Giorni</option>
<option value="30" ${selectedRange === '30' ? 'selected' : ''}>Ultimi 30 Giorni</option>
<option value="90" ${selectedRange === '90' ? 'selected' : ''}>Ultimi 3 Mesi (90 gg)</option>
<option value="180" ${selectedRange === '180' ? 'selected' : ''}>Ultimi 6 Mesi (180 gg)</option>
<option value="365" ${selectedRange === '365' ? 'selected' : ''}>Ultimo Anno (365 gg)</option>
</select>
<button id="ta-chart-modal-close" class="btn btn-ghost btn-sm" style="font-size:1.2rem; line-height:1; padding:4px 10px; cursor:pointer;">✕</button>
</div>
</div>
<!-- Content Body (Chart + Agents Sidebar) -->
<div style="display:flex; gap:var(--space-md); flex:1; min-height:0; overflow:hidden;">
<!-- Main Canvas Area -->
<div style="flex:1; position:relative; min-height:420px; background:var(--bg-secondary); border-radius:var(--radius-md); padding:16px; border:1px solid var(--border-subtle); display:flex; flex-direction:column;">
<div id="ta-chart-loading" style="display:none; position:absolute; inset:0; background:rgba(0,0,0,0.3); backdrop-filter:blur(2px); z-index:10; align-items:center; justify-content:center; color:#fff; font-weight:600; font-size:0.9rem; border-radius:var(--radius-md);">
<div class="spinner" style="margin-right:10px;"></div> Caricamento dati...
</div>
<div style="flex:1; position:relative; width:100%; height:100%;">
<canvas id="ta-chart-canvas"></canvas>
</div>
</div>
<!-- Right Sidebar: Agents Selection Toggle List -->
<div style="width:270px; flex-shrink:0; background:var(--bg-secondary); border-radius:var(--radius-md); border:1px solid var(--border-subtle); padding:12px; display:flex; flex-direction:column; gap:10px;">
<div style="font-size:0.8rem; font-weight:700; color:var(--text-primary); border-bottom:1px solid var(--border-subtle); padding-bottom:6px; display:flex; justify-content:space-between; align-items:center;">
<span>👥 Agenti (${users.length})</span>
<button id="ta-chart-select-all" class="btn btn-ghost btn-sm" style="font-size:0.7rem; padding:2px 6px;">Tutti</button>
</div>
<div id="ta-chart-agents-list" style="flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:6px; padding-right:4px;">
${users.map((u, idx) => {
const uId = String(u.id);
const isBanned = bannedAgentIds.includes(uId);
const isChecked = !isBanned && activeAgentIds.includes(uId);
const color = colorPalette[idx % colorPalette.length];
return `
<div class="ta-agent-toggle-item ${isBanned ? 'banned' : ''}" data-id="${uId}" style="display:flex; align-items:center; gap:6px; padding:5px 8px; border-radius:6px; background:${isChecked ? 'var(--bg-tertiary)' : 'transparent'}; border:1px solid ${isChecked ? 'var(--border-subtle)' : 'transparent'}; font-size:0.8rem; user-select:none; transition:all 0.15s ease; ${isBanned ? 'opacity:0.4; pointer-events:none; cursor:not-allowed;' : ''}">
<input type="checkbox" class="ta-agent-checkbox" value="${uId}" ${isChecked ? 'checked' : ''} ${isBanned ? 'disabled' : ''} style="cursor:pointer;" />
<span style="width:10px; height:10px; border-radius:50%; background:${color}; flex-shrink:0;"></span>
<span style="font-weight:${isChecked ? '600' : 'normal'}; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;" title="${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}">
${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}
</span>
<span class="ta-agent-total-badge" style="font-size:0.72rem; font-weight:700; font-family:monospace; color:var(--text-muted); background:var(--bg-card); border:1px solid var(--border-subtle); padding:1px 5px; border-radius:10px; flex-shrink:0;">
0h
</span>
<button type="button" class="ta-agent-ban-btn" data-id="${uId}" style="pointer-events:auto; background:none; border:none; cursor:pointer; font-size:0.85rem; padding:0 2px; line-height:1; opacity:${isBanned ? '1' : '0.5'}; transition:opacity 0.15s ease;" title="${isBanned ? 'Sblocca agente' : 'Blocca agente (Escludi dal grafico e dal pulsante Tutti)'}">
🚫
</button>
</div>
`;
}).join('')}
</div>
</div>
</div>
<!-- Footer Stats -->
<div id="ta-chart-footer-info" style="display:flex; justify-content:space-between; align-items:center; font-size:0.8rem; color:var(--text-muted); border-top:1px solid var(--border-subtle); padding-top:var(--space-sm);">
<span>Agenti selezionati: <strong id="ta-chart-agents-count">${activeAgentIds.length}</strong></span>
<span>Target Giornaliero: <strong>8.0h (480 min)</strong></span>
</div>
</div>
`;
document.body.appendChild(modal);
const closeModal = () => {
if (this.chartInstance) {
this.chartInstance.destroy();
this.chartInstance = null;
}
modal.remove();
};
modal.querySelector('#ta-chart-modal-close').onclick = closeModal;
modal.onclick = (e) => {
if (e.target === modal) closeModal();
};
// Function to fetch data and refresh chart instance
const refreshChartData = async () => {
const loadingEl = modal.querySelector('#ta-chart-loading');
if (loadingEl) loadingEl.style.display = 'flex';
const checkboxes = modal.querySelectorAll('.ta-agent-checkbox:checked');
const selectedIds = Array.from(checkboxes).map(c => c.value);
// Save state to localStorage
localStorage.setItem('otrs_ta_chart_agents', JSON.stringify(selectedIds));
localStorage.setItem('otrs_ta_chart_range', selectedRange);
const countEl = modal.querySelector('#ta-chart-agents-count');
if (countEl) countEl.textContent = selectedIds.length;
if (selectedIds.length === 0) {
if (this.chartInstance) {
this.chartInstance.destroy();
this.chartInstance = null;
}
if (loadingEl) loadingEl.style.display = 'none';
return;
}
try {
const params = new URLSearchParams({
agent_ids: selectedIds.join(','),
date: this.selectedDate,
range: selectedRange,
});
const data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
const dailyTotalsByAgent = data.daily_totals_by_agent || {};
const targetMins = data.target_minutes || 480;
const targetHoursVal = +(targetMins / 60).toFixed(2);
// Compute set of all dates across selected agents
let allDates = [];
Object.values(dailyTotalsByAgent).forEach(agentTotals => {
Object.keys(agentTotals).forEach(d => {
if (!allDates.includes(d)) allDates.push(d);
});
});
allDates.sort();
// If numeric range (e.g. last 30, 90, 180, 365 days), slice dates
if (selectedRange !== 'all' && !isNaN(parseInt(selectedRange, 10))) {
const limitDays = parseInt(selectedRange, 10);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - limitDays);
const cutoffIso = this.formatLocalISO(cutoff);
allDates = allDates.filter(d => d >= cutoffIso);
}
const labels = allDates.map(d => {
const parts = d.split('-');
return `${parts[2]}/${parts[1]}`;
});
// Update total hours badges for all agents in the sidebar list
users.forEach((u) => {
const uId = String(u.id);
const agentTotals = dailyTotalsByAgent[uId] || {};
let totalMinsForAgent = 0;
allDates.forEach(d => {
totalMinsForAgent += (agentTotals[d] || 0);
});
const totalHoursVal = (totalMinsForAgent / 60).toFixed(1);
const badgeEl = modal.querySelector(`.ta-agent-toggle-item[data-id="${uId}"] .ta-agent-total-badge`);
if (badgeEl) {
badgeEl.textContent = `${totalHoursVal}h`;
if (totalMinsForAgent > 0) {
badgeEl.style.color = 'var(--accent-primary)';
badgeEl.style.borderColor = 'rgba(59, 130, 246, 0.3)';
} else {
badgeEl.style.color = 'var(--text-muted)';
badgeEl.style.borderColor = 'var(--border-subtle)';
}
}
});
// Build dataset per agent
const datasets = selectedIds.map((agId) => {
const userObj = users.find(u => String(u.id) === String(agId));
const idx = users.findIndex(u => String(u.id) === String(agId));
const color = colorPalette[idx >= 0 ? idx % colorPalette.length : 0];
const agentTotals = dailyTotalsByAgent[agId] || {};
const hoursData = allDates.map(d => {
const mins = agentTotals[d] || 0;
return +(mins / 60).toFixed(2);
});
return {
label: userObj ? `${userObj.first_name} ${userObj.last_name}` : `Agente #${agId}`,
data: hoursData,
borderColor: color,
backgroundColor: color + '22',
borderWidth: 2,
fill: false,
tension: 0.25,
pointBackgroundColor: color,
pointRadius: 1.5,
pointHoverRadius: 5,
};
});
// Add Target reference line
datasets.push({
label: 'Soglia Target (Ore)',
data: allDates.map(() => targetHoursVal),
borderColor: '#ef4444',
borderDash: [5, 5],
borderWidth: 2,
fill: false,
pointRadius: 0,
});
if (this.chartInstance) {
this.chartInstance.destroy();
}
if (window.Chart) {
const ctx = modal.querySelector('#ta-chart-canvas').getContext('2d');
this.chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
const val = context.raw || 0;
const mins = Math.round(val * 60);
const h = Math.floor(mins / 60);
const m = mins % 60;
const timeFormatted = h > 0 ? `${h}h ${m}m` : `${m}m`;
return `${context.dataset.label}: ${timeFormatted} (${val}h)`;
}
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, font: { family: 'Inter', size: 10 } }
},
y: {
grid: { color: gridColor },
beginAtZero: true,
ticks: {
color: textColor,
font: { family: 'Inter', size: 10 },
callback: function(value) {
return value + 'h';
}
}
}
}
}
});
}
} catch (err) {
console.error('Error loading chart data:', err);
} finally {
if (loadingEl) loadingEl.style.display = 'none';
}
};
// Range selector change event
const rangeSelect = modal.querySelector('#ta-chart-range-select');
if (rangeSelect) {
rangeSelect.onchange = (e) => {
selectedRange = e.target.value;
refreshChartData();
};
}
// Agent checkboxes toggle events
modal.querySelectorAll('.ta-agent-checkbox').forEach(chk => {
chk.onchange = (e) => {
const itemLabel = e.target.closest('.ta-agent-toggle-item');
if (itemLabel) {
itemLabel.style.background = e.target.checked ? 'var(--bg-tertiary)' : 'transparent';
itemLabel.style.borderColor = e.target.checked ? 'var(--border-subtle)' : 'transparent';
}
refreshChartData();
};
});
// Agent Ban/Unban toggle button events 🚫
modal.querySelectorAll('.ta-agent-ban-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const uId = String(btn.dataset.id);
const itemLabel = btn.closest('.ta-agent-toggle-item');
const chk = itemLabel ? itemLabel.querySelector('.ta-agent-checkbox') : null;
const isCurrentlyBanned = bannedAgentIds.includes(uId);
if (isCurrentlyBanned) {
bannedAgentIds = bannedAgentIds.filter(id => id !== uId);
if (itemLabel) {
itemLabel.classList.remove('banned');
itemLabel.style.opacity = '1';
itemLabel.style.pointerEvents = 'auto';
}
if (chk) {
chk.disabled = false;
}
btn.title = 'Blocca agente (Escludi dal grafico e dal pulsante Tutti)';
btn.style.opacity = '0.5';
} else {
bannedAgentIds.push(uId);
if (itemLabel) {
itemLabel.classList.add('banned');
itemLabel.style.opacity = '0.4';
itemLabel.style.pointerEvents = 'none';
itemLabel.style.background = 'transparent';
itemLabel.style.borderColor = 'transparent';
}
if (chk) {
chk.checked = false;
chk.disabled = true;
}
btn.title = 'Sblocca agente';
btn.style.opacity = '1';
}
localStorage.setItem('otrs_ta_chart_banned_agents', JSON.stringify(bannedAgentIds));
refreshChartData();
};
});
// Select All agents button (ignores banned agents)
const selectAllBtn = modal.querySelector('#ta-chart-select-all');
if (selectAllBtn) {
selectAllBtn.onclick = () => {
// Only select/unselect checkboxes that are not disabled/banned
const activeCheckboxes = modal.querySelectorAll('.ta-agent-checkbox:not(:disabled)');
const allChecked = Array.from(activeCheckboxes).every(c => c.checked);
activeCheckboxes.forEach(chk => {
chk.checked = !allChecked;
const itemLabel = chk.closest('.ta-agent-toggle-item');
if (itemLabel) {
itemLabel.style.background = chk.checked ? 'var(--bg-tertiary)' : 'transparent';
itemLabel.style.borderColor = chk.checked ? 'var(--border-subtle)' : 'transparent';
}
});
refreshChartData();
};
}
// Initial render
refreshChartData();
}
};