1318 lines
61 KiB
JavaScript
1318 lines
61 KiB
JavaScript
/**
|
||
* 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',
|
||
groupByTicket: localStorage.getItem('otrs_ta_group_by_ticket') !== 'false',
|
||
|
||
groupArticles(articles) {
|
||
if (!articles || articles.length === 0) return [];
|
||
if (!this.groupByTicket) {
|
||
return articles.map(a => ({ type: 'single', article: a }));
|
||
}
|
||
|
||
const groups = [];
|
||
let currentGroup = null;
|
||
|
||
for (const a of articles) {
|
||
const ticketId = a.ticket_id;
|
||
if (currentGroup && currentGroup.ticket_id === ticketId) {
|
||
currentGroup.articles.push(a);
|
||
} else {
|
||
if (currentGroup) {
|
||
groups.push(currentGroup);
|
||
}
|
||
currentGroup = {
|
||
type: 'group',
|
||
ticket_id: ticketId,
|
||
ticket_tn: a.ticket_tn,
|
||
ticket_title: a.ticket_title,
|
||
articles: [a]
|
||
};
|
||
}
|
||
}
|
||
if (currentGroup) {
|
||
groups.push(currentGroup);
|
||
}
|
||
return groups;
|
||
},
|
||
|
||
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-groupby" class="btn btn-ghost btn-sm" style="height:36px; display:flex; align-items:center; gap:6px; background:${this.groupByTicket ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; color:${this.groupByTicket ? '#ffffff' : 'var(--text-secondary)'}; border:1px solid var(--border-subtle);" title="Raggruppa le note adiacenti appartenenti allo stesso ticket">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px; height:15px;">
|
||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||
</svg>
|
||
<span>${this.groupByTicket ? 'Raggruppa per Ticket (Attivo)' : 'Raggruppa per Ticket'}</span>
|
||
</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-md);">
|
||
${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>
|
||
` : this.groupArticles(articles).map(g => g.type === 'single' ? this.buildArticleCard(g.article) : this.buildGroupCard(g)).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 if (dayMins === 0) {
|
||
// Giorno lavorativo senza consuntivazioni: neutro / bianco
|
||
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 con consuntivazione (> 0 min)
|
||
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 (dayMins === 0) {
|
||
// Giorno lavorativo senza ore consuntivate: neutro / non evidenziato in rosso
|
||
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>
|
||
`;
|
||
},
|
||
|
||
buildGroupCard(group) {
|
||
const totalGroupMins = Math.round(group.articles.reduce((acc, a) => acc + (parseFloat(a.time_unit) || 0), 0));
|
||
const formattedGroupTime = this.formatPillTime(totalGroupMins);
|
||
const hasSelectedArticle = group.articles.some(a => String(a.article_id) === String(this.selectedArticleId));
|
||
|
||
return `
|
||
<div class="card ta-group-card" style="border:1px solid ${hasSelectedArticle ? 'var(--accent-primary)' : 'var(--border-subtle)'}; background:var(--bg-card); border-radius:var(--radius-md); overflow:hidden;">
|
||
<!-- Header del Gruppo Ticket -->
|
||
<div style="padding:10px 14px; background:var(--bg-secondary); border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; gap:10px;">
|
||
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||
<span style="font-size:0.9rem;">🎫</span>
|
||
<a href="#/tickets/${group.ticket_id}" class="ticket-tn-link" onclick="event.stopPropagation();" style="font-weight:700; font-size:0.9rem;">#${App.escapeHtml(group.ticket_tn || group.ticket_id)}</a>
|
||
<span style="font-size:0.85rem; font-weight:600; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${App.escapeHtml(group.ticket_title || '')}">
|
||
${App.escapeHtml(group.ticket_title || '(senza titolo)')}
|
||
</span>
|
||
</div>
|
||
<div style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
|
||
<span class="badge" style="background:var(--bg-tertiary); color:var(--text-secondary); font-size:0.75rem; font-weight:600;">
|
||
${group.articles.length} ${group.articles.length === 1 ? 'nota' : 'note'}
|
||
</span>
|
||
<span class="badge" style="background:${totalGroupMins > 0 ? 'rgba(34,197,94,0.18)' : 'var(--bg-tertiary)'}; color:${totalGroupMins > 0 ? '#15803d' : 'var(--text-muted)'}; font-weight:700; font-size:0.78rem;">
|
||
⏱️ Totale: ${formattedGroupTime} (${totalGroupMins} m)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Note del Gruppo -->
|
||
<div style="display:flex; flex-direction:column; gap:6px; padding:10px;">
|
||
${group.articles.map(a => this.buildArticleCard(a)).join('')}
|
||
</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();
|
||
});
|
||
|
||
// Toggle group by ticket button
|
||
document.getElementById('ta-btn-toggle-groupby')?.addEventListener('click', () => {
|
||
this.groupByTicket = !this.groupByTicket;
|
||
localStorage.setItem('otrs_ta_group_by_ticket', this.groupByTicket);
|
||
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();
|
||
}
|
||
};
|