fix: ora le ricerche asincrone non si sovrappongono più alle attività. feat: aggiunta funzione Storico Consuntivazione

This commit is contained in:
VVER440 committed 2026-08-21 00:07:08 +02:00
1 parent ba23635c7d
commit 3650bf5790
9 files changed
+737 -33

No files matched your search

+509
View File
@@ -0,0 +1,509 @@
/**
* 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,
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,
});
const 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;
}
container.innerHTML = this.buildHtml(data);
this.bindEvents();
// 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);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px; height:20px; color:var(--accent-primary);">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<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>
<!-- Interactive Day Pills Strip with Target-based Color Coding -->
${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 7 days before and 7 days after selected date (15 days total)
const days = [];
for (let offset = -7; offset <= 7; offset++) {
const d = new Date(selected);
d.setDate(d.getDate() + offset);
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;
// Filter out non-working days if toggle is off, unless it's currently selected
if (!isWorkDay && !this.showNonWorkingDays && !isSelected) {
continue;
}
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>
`;
},
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() {
// 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);
const container = document.getElementById('view-container');
container.innerHTML = this.buildHtml(this.summaryData);
this.bindEvents();
});
// Day pill clicks
document.querySelectorAll('.ta-day-pill[data-date]').forEach(pill => {
pill.addEventListener('click', () => {
this.selectedDate = pill.dataset.date;
this.loadData();
});
});
// Previous day button
document.getElementById('ta-day-prev')?.addEventListener('click', () => {
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
d.setDate(d.getDate() - 1);
this.selectedDate = this.formatLocalISO(d);
this.loadData();
});
// Next day button
document.getElementById('ta-day-next')?.addEventListener('click', () => {
const parts = this.selectedDate.split('-').map(Number);
const d = new Date(parts[0], parts[1] - 1, parts[2]);
d.setDate(d.getDate() + 1);
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 container = document.getElementById('view-container');
container.innerHTML = this.buildHtml(this.summaryData);
this.bindEvents();
});
});
// 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;
}
});
}
}
};