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

510 lines
24 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,
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;
}
});
}
}
};