diff --git a/.env.example b/.env.example index 346e01b..00d8e05 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ OTRS_API_URL=http://your_otrs_host_or_ip/otrs/nph-genericinterface.pl/Webservice #Variabile per il Tempo di consultivazione giornaliero DAILY_TARGET_TIME=480 +# Giorni lavorativi della settimana (0=Domenica, 1=Lunedì, 2=Martedì, 3=Mercoledì, 4=Giovedì, 5=Venerdì, 6=Sabato) +WORK_DAYS=1,2,3,4,5 #Variabile per la numerazione dei ticket OTRS_SYSTEM_ID=10 diff --git a/public/index.html b/public/index.html index 796b208..6faea14 100644 --- a/public/index.html +++ b/public/index.html @@ -111,6 +111,15 @@ Gestione Mail +
  • + + + + + + Storico consuntivazione + +
  • @@ -213,6 +222,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 060a1a7..cef1e2e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -320,6 +320,11 @@ const App = { titleEl.textContent = 'Gestione Mail'; MailManagementView.render(); + } else if (hash === '#/time-accounting') { + document.getElementById('nav-time-accounting')?.classList.add('active'); + titleEl.textContent = 'Storico consuntivazione'; + TimeAccountingView.render(); + } else if (hash.match(/^#\/tickets\/(\d+)$/)) { const id = hash.match(/^#\/tickets\/(\d+)$/)[1]; document.getElementById('nav-tickets')?.classList.add('active'); diff --git a/public/js/views/activityLog.js b/public/js/views/activityLog.js index 3148b44..88017a6 100644 --- a/public/js/views/activityLog.js +++ b/public/js/views/activityLog.js @@ -1,4 +1,4 @@ -/** +/** * ActivityLogView — Storico Attività * Displays the local SQLite activity log with filters, dual pagination, * and an expandable JSON detail panel. @@ -49,9 +49,10 @@ const ActivityLogView = { - - - + + + + @@ -77,8 +78,42 @@ const ActivityLogView = { `; }, + _extractTicketRef(r) { + if (!r.azione) return '—'; + try { + const data = typeof r.azione === 'string' ? JSON.parse(r.azione) : r.azione; + if (!data) return '—'; + + const tn = data.tn || data.ticket_tn || data.target_tn; + const id = data.ticket_id || data.id || data.target_id; + + if (tn && id) { + return `#${App.escapeHtml(String(tn))}`; + } else if (tn) { + return `#${App.escapeHtml(String(tn))}`; + } else if (id) { + return `#${id}`; + } + + if (Array.isArray(data.tns) && data.tns.length > 0) { + return data.tns.map(t => `#${App.escapeHtml(String(t))}`).join(', '); + } + if (Array.isArray(data.source_tns) && data.source_tns.length > 0) { + return data.source_tns.map(t => `#${App.escapeHtml(String(t))}`).join(', '); + } + if (Array.isArray(data.ticket_ids) && data.ticket_ids.length > 0) { + return data.ticket_ids.map(tid => `#${tid}`).join(', '); + } + + return '—'; + } catch (_) { + return '—'; + } + }, + _buildRow(r) { const dt = r.creato_il ? new Date(r.creato_il).toLocaleString('it-IT') : '—'; + const ticketRef = this._extractTicketRef(r); const esitoBadge = r.esito === 'successo' ? `✓ successo` : `✕ errore`; @@ -88,13 +123,14 @@ const ActivityLogView = { + - diff --git a/public/js/views/ticketCreate.js b/public/js/views/ticketCreate.js index f0f256d..245d4ac 100644 --- a/public/js/views/ticketCreate.js +++ b/public/js/views/ticketCreate.js @@ -659,6 +659,9 @@ const TicketCreateView = { // Update daily timer App.updateDailyTimer(); + // Invalidate list cache so newly created ticket appears in lists + TicketListView.invalidateCache(); + // Navigate to the new ticket window.location.hash = `#/tickets/${result.id}`; diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js index 26749d5..fc0bb54 100644 --- a/public/js/views/ticketDetail.js +++ b/public/js/views/ticketDetail.js @@ -94,6 +94,13 @@ const TicketDetailView = { try { await App.ensureLookups(); const data = await App.api(`/api/tickets/${id}`); + + // Route guard: verify user is still on this ticket's detail page + const currentHash = (window.location.hash || '').split('?')[0]; + if (currentHash !== `#/tickets/${id}`) { + return; + } + const { ticket, articles, attachments } = data; // No automatic tab creation here anymore @@ -838,6 +845,7 @@ const TicketDetailView = { body: JSON.stringify(updates), }); Toast.success(res.message || 'Ticket aggiornato!'); + TicketListView.invalidateCache(); // Refresh the view this.render(this.ticketId); } catch (err) { diff --git a/public/js/views/ticketList.js b/public/js/views/ticketList.js index f2e1b0d..366d76b 100644 --- a/public/js/views/ticketList.js +++ b/public/js/views/ticketList.js @@ -9,10 +9,51 @@ const TicketListView = { sortDir: 'DESC', selectedIds: new Set(), selectedOrder: [], - searchTimeout: null, + lastQueryParams: null, + cachedData: null, + needsRefresh: false, - async render() { + invalidateCache() { + this.lastQueryParams = null; + this.cachedData = null; + }, + + async render(force = false) { + const isMyTickets = window.location.hash.startsWith('#/tickets/my'); + Filters.currentMode = isMyTickets ? 'my' : 'general'; + Filters.load(); // Load state for current mode + + if (isMyTickets) { + const activeAgentId = localStorage.getItem('activeAgentId') || '1'; + Filters.state.user_id = activeAgentId; + Filters.save(); + } + + const params = Filters.toQueryParams(); + params.set('page', this.currentPage); + params.set('per_page', this.perPage); + params.set('sort_by', this.sortBy); + params.set('sort_dir', this.sortDir); + + const searchInput = document.getElementById('global-search'); + if (searchInput && searchInput.value.trim()) { + params.set('search', searchInput.value.trim()); + } + + const currentQueryString = `${Filters.currentMode}:${params.toString()}`; const container = document.getElementById('view-container'); + + // Return cached results instantly if query params haven't changed and no force refresh requested + if (!force && !this.needsRefresh && this.lastQueryParams === currentQueryString && this.cachedData) { + const currentHash = (window.location.hash || '#/dashboard').split('?')[0]; + if (currentHash !== '#/tickets' && currentHash !== '#/tickets/my') { + return; + } + this.renderContent(container, this.cachedData); + this.bindEvents(this.cachedData); + return; + } + if (!container.querySelector('.ticket-table-wrapper') && !container.querySelector('.ticket-table')) { container.innerHTML = '

    Caricamento ticket...

    '; } @@ -31,34 +72,28 @@ const TicketListView = { console.warn('Failed to load agent settings:', err); } - // Build query params - const isMyTickets = window.location.hash.startsWith('#/tickets/my'); - Filters.currentMode = isMyTickets ? 'my' : 'general'; - Filters.load(); // Load state for current mode - - if (isMyTickets) { - const activeAgentId = localStorage.getItem('activeAgentId') || '1'; - Filters.state.user_id = activeAgentId; - Filters.save(); - } - - const params = Filters.toQueryParams(); - params.set('page', this.currentPage); - params.set('per_page', this.perPage); - params.set('sort_by', this.sortBy); - params.set('sort_dir', this.sortDir); - - const searchInput = document.getElementById('global-search'); - if (searchInput && searchInput.value.trim()) { - params.set('search', searchInput.value.trim()); - } - const data = await App.api(`/api/tickets?${params.toString()}`); + // Route guard: if the user navigated away while the async search was in flight, do not overwrite the view + const currentHash = (window.location.hash || '#/dashboard').split('?')[0]; + if (currentHash !== '#/tickets' && currentHash !== '#/tickets/my') { + return; + } + + // Save to cache + this.lastQueryParams = currentQueryString; + this.cachedData = data; + this.needsRefresh = false; + this.renderContent(container, data); this.bindEvents(data); } catch (err) { + const currentHash = (window.location.hash || '#/dashboard').split('?')[0]; + if (currentHash !== '#/tickets' && currentHash !== '#/tickets/my') { + return; + } + container.innerHTML = `
    ⚠️
    @@ -679,6 +714,7 @@ const TicketListView = { Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`); this.selectedIds.clear(); this.selectedOrder = []; + this.invalidateCache(); this.render(); } catch (err) { Toast.error('Errore aggiornamento batch: ' + err.message); @@ -727,6 +763,8 @@ const TicketListView = { Toast.success(res.message || 'Ticket uniti con successo'); this.selectedIds.clear(); this.selectedOrder = []; + this.invalidateCache(); + this.render(); } catch (err) { Toast.error('Errore durante l\'unione: ' + err.message); this.updateBatchBar(); @@ -799,6 +837,7 @@ const TicketListView = { Toast.success(`${addedCount} ticket aggiunti al gruppo!`); this.selectedIds.clear(); this.selectedOrder = []; + this.invalidateCache(); this.render(); } else if (errorsCount > 0) { Toast.warning('I ticket selezionati appartengono già a questo gruppo.'); diff --git a/public/js/views/timeAccounting.js b/public/js/views/timeAccounting.js new file mode 100644 index 0000000..4a6745c --- /dev/null +++ b/public/js/views/timeAccounting.js @@ -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 = '

    Caricamento consuntivazione...

    '; + + 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 = `

    Errore caricamento: ${App.escapeHtml(err.message)}

    `; + } + }, + + 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 ` +
    +
    + + + + + Storico consuntivazione + + ${headerBadgeText} + +
    +
    + + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + + + ${this.buildDayStrip(data)} + + +
    + + +
    + ${articles.length === 0 ? ` +
    + + + +

    Nessuna nota o email trovata in questa data per l'agente selezionato.

    +
    + ` : articles.map(a => this.buildArticleCard(a)).join('')} +
    + + +
    + ${selectedArticle ? this.buildDetailPanel(selectedArticle) : ` +
    + Seleziona una nota o email a sinistra per gestirne il tempo o retrodatare. +
    + `} +
    +
    + `; + }, + + 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(` + + `); + } + + return ` +
    + +
    + ${days.join('')} +
    + +
    + `; + }, + + 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 ` +
    +
    +
    + #${App.escapeHtml(a.ticket_tn || a.ticket_id)} + ${App.escapeHtml(a.ticket_title || '')} +
    + ${dt} +
    + +
    + ${App.escapeHtml(a.a_subject || '(senza oggetto)')} +
    + +
    + ${bodyPreview || 'Nessun testo'} +
    + +
    + ${App.escapeHtml(a.channel || a.sender_type || 'Nota')} + + ⏱️ ${timeMins} min + +
    +
    + `; + }, + + 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 ` +
    +
    +
    Ticket Collegato
    + + #${App.escapeHtml(a.ticket_tn || a.ticket_id)} — ${App.escapeHtml(a.ticket_title || '')} + +
    + +
    +
    Oggetto Nota/Mail
    +
    + ${App.escapeHtml(a.a_subject || '(senza oggetto)')} +
    +
    + +
    +
    Contenuto
    +
    + ${App.escapeHtml(a.a_body || '')} +
    +
    + +
    + + +
    + +
    + + +
    +
    + + +
    + +
    + + +
    +
    +
    + `; + }, + + 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; + } + }); + } + } +}; diff --git a/routes/tickets.js b/routes/tickets.js index d0851a2..945e7d1 100644 --- a/routes/tickets.js +++ b/routes/tickets.js @@ -754,7 +754,7 @@ router.patch('/:id', async (req, res) => { let current; try { const currentResult = await pool.query( - `SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, responsible_user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id + `SELECT tn, ticket_state_id, ticket_priority_id, queue_id, user_id, responsible_user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id FROM ticket WHERE id = $1`, [id] ); @@ -832,7 +832,7 @@ router.patch('/:id', async (req, res) => { agente_id: operatorId, agente_nome, titolo_azione: isClosing ? 'Chiusura Ticket' : 'Modifica Rapida Ticket', - azione: { ticket_id: id, ...updates }, + azione: { ticket_id: id, tn: current.tn, ...updates }, esito: 'successo', }); }); @@ -1048,7 +1048,7 @@ router.patch('/:id', async (req, res) => { agente_id: operatorId, agente_nome, titolo_azione: isClosingDB ? 'Chiusura Ticket' : 'Modifica Rapida Ticket', - azione: { ticket_id: id, ...updates }, + azione: { ticket_id: id, tn: current.tn, ...updates }, esito: 'successo', }); }); @@ -1392,7 +1392,7 @@ router.post('/:id/articles', async (req, res) => { agente_id: operatorId, agente_nome, titolo_azione: 'Aggiunta Nota', - azione: { ticket_id: id, subject, time_unit, has_attachments: !!(attachments && attachments.length) }, + azione: { ticket_id: id, tn: t.tn, subject, time_unit, has_attachments: !!(attachments && attachments.length) }, esito: 'successo', }); }); @@ -1874,6 +1874,98 @@ router.post('/merge', async (req, res) => { } }); +// ============================================================ +// GET /api/tickets/time-accounting/summary — Summary of notes/emails and time per agent per day +// ============================================================ +router.get('/time-accounting/summary', async (req, res) => { + try { + const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1; + const agentId = parseInt(req.query.agent_id, 10) || operatorId; + const dateStr = req.query.date || new Date().toISOString().slice(0, 10); + + const targetDate = new Date(`${dateStr}T00:00:00Z`); + const startDateObj = new Date(targetDate); + startDateObj.setDate(startDateObj.getDate() - 7); + const endDateObj = new Date(targetDate); + endDateObj.setDate(endDateObj.getDate() + 7); + + const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`; + const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`; + + const result = await pool.query( + `SELECT + a.id AS article_id, + a.ticket_id, + t.tn AS ticket_tn, + t.title AS ticket_title, + ast.name AS sender_type, + cc.name AS channel, + adm.a_subject, + adm.a_body, + adm.a_from, + adm.a_to, + a.create_time, + COALESCE(ta.time_unit, 0) AS time_unit + FROM article a + JOIN ticket t ON a.ticket_id = t.id + JOIN article_sender_type ast ON a.article_sender_type_id = ast.id + LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id + LEFT JOIN article_data_mime adm ON a.id = adm.article_id + LEFT JOIN ( + SELECT article_id, SUM(time_unit) AS time_unit + FROM time_accounting + GROUP BY article_id + ) ta ON a.id = ta.article_id + WHERE a.create_by = $1 + AND a.create_time >= $2 + AND a.create_time <= $3 + ORDER BY a.create_time DESC, a.id DESC`, + [agentId, startTime, endTime] + ); + + const dailyTotals = {}; + const dayArticles = []; + let totalMinutes = 0; + + result.rows.forEach(r => { + let isoDate = ''; + if (r.create_time instanceof Date) { + const y = r.create_time.getUTCFullYear(); + const m = String(r.create_time.getUTCMonth() + 1).padStart(2, '0'); + const d = String(r.create_time.getUTCDate()).padStart(2, '0'); + isoDate = `${y}-${m}-${d}`; + } else { + isoDate = String(r.create_time || '').slice(0, 10); + } + const timeVal = parseFloat(r.time_unit || 0); + + dailyTotals[isoDate] = (dailyTotals[isoDate] || 0) + timeVal; + + if (isoDate === dateStr) { + dayArticles.push(r); + totalMinutes += timeVal; + } + }); + + const targetMinutes = parseInt(process.env.DAILY_TARGET_TIME, 10) || 480; + const workDaysRaw = process.env.WORK_DAYS || '1,2,3,4,5'; + const workDays = workDaysRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x)); + + res.json({ + agent_id: agentId, + date: dateStr, + total_minutes: totalMinutes, + target_minutes: targetMinutes, + work_days: workDays, + articles: dayArticles, + daily_totals: dailyTotals, + }); + } catch (err) { + console.error('Error fetching time accounting summary:', err); + res.status(500).json({ error: err.message }); + } +}); + // ============================================================ // PUT /api/articles/:articleId/time — Update time accounted for an article // ============================================================
    Data/OraAgenteAzioneData/OraAgenteAzioneTicket (TN) Esito ≡
    ${dt} ${App.escapeHtml(r.agente_nome || '—')} ${App.escapeHtml(r.titolo_azione)}${ticketRef} ${esitoBadge}