fix: ora le ricerche asincrone non si sovrappongono più alle attività. feat: aggiunta funzione Storico Consuntivazione
This commit is contained in:
1 parent
ba23635c7d
commit
3650bf5790
9 files changed
+737
-33
No files matched your search
@@ -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
|
#Variabile per il Tempo di consultivazione giornaliero
|
||||||
DAILY_TARGET_TIME=480
|
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
|
#Variabile per la numerazione dei ticket
|
||||||
OTRS_SYSTEM_ID=10
|
OTRS_SYSTEM_ID=10
|
||||||
|
|||||||
@@ -111,6 +111,15 @@
|
|||||||
<span>Gestione Mail</span>
|
<span>Gestione Mail</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/time-accounting" class="nav-link" data-view="time-accounting" id="nav-time-accounting">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span>Storico consuntivazione</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/activity" class="nav-link" data-view="activity" id="nav-activity">
|
<a href="#/activity" class="nav-link" data-view="activity" id="nav-activity">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -213,6 +222,7 @@
|
|||||||
<script src="/js/views/activityLog.js"></script>
|
<script src="/js/views/activityLog.js"></script>
|
||||||
<script src="/js/views/emailCompose.js"></script>
|
<script src="/js/views/emailCompose.js"></script>
|
||||||
<script src="/js/views/mailManagement.js"></script>
|
<script src="/js/views/mailManagement.js"></script>
|
||||||
|
<script src="/js/views/timeAccounting.js"></script>
|
||||||
<script src="/js/views/ticketGroups.js"></script>
|
<script src="/js/views/ticketGroups.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -320,6 +320,11 @@ const App = {
|
|||||||
titleEl.textContent = 'Gestione Mail';
|
titleEl.textContent = 'Gestione Mail';
|
||||||
MailManagementView.render();
|
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+)$/)) {
|
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||||
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
||||||
document.getElementById('nav-tickets')?.classList.add('active');
|
document.getElementById('nav-tickets')?.classList.add('active');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* ActivityLogView — Storico Attività
|
* ActivityLogView — Storico Attività
|
||||||
* Displays the local SQLite activity log with filters, dual pagination,
|
* Displays the local SQLite activity log with filters, dual pagination,
|
||||||
* and an expandable JSON detail panel.
|
* and an expandable JSON detail panel.
|
||||||
@@ -49,9 +49,10 @@ const ActivityLogView = {
|
|||||||
<table class="tickets-table" id="activity-table" style="width:100%;">
|
<table class="tickets-table" id="activity-table" style="width:100%;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:160px;">Data/Ora</th>
|
<th style="width:150px;">Data/Ora</th>
|
||||||
<th style="width:160px;">Agente</th>
|
<th style="width:140px;">Agente</th>
|
||||||
<th style="width:180px;">Azione</th>
|
<th style="width:160px;">Azione</th>
|
||||||
|
<th style="width:140px;">Ticket (TN)</th>
|
||||||
<th style="min-width:60px;text-align:center;">Esito</th>
|
<th style="min-width:60px;text-align:center;">Esito</th>
|
||||||
<th style="width:48px;text-align:center;">≡</th>
|
<th style="width:48px;text-align:center;">≡</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -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 `<a href="#/tickets/${id}" class="ticket-tn-link" onclick="event.stopPropagation();">#${App.escapeHtml(String(tn))}</a>`;
|
||||||
|
} else if (tn) {
|
||||||
|
return `<span style="font-family:var(--font-mono, monospace);font-weight:600;color:var(--accent-primary);">#${App.escapeHtml(String(tn))}</span>`;
|
||||||
|
} else if (id) {
|
||||||
|
return `<a href="#/tickets/${id}" class="ticket-tn-link" onclick="event.stopPropagation();">#${id}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.tns) && data.tns.length > 0) {
|
||||||
|
return data.tns.map(t => `<span style="font-family:var(--font-mono, monospace);font-size:0.78rem;font-weight:600;color:var(--accent-primary);">#${App.escapeHtml(String(t))}</span>`).join(', ');
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.source_tns) && data.source_tns.length > 0) {
|
||||||
|
return data.source_tns.map(t => `<span style="font-family:var(--font-mono, monospace);font-size:0.78rem;font-weight:600;color:var(--accent-primary);">#${App.escapeHtml(String(t))}</span>`).join(', ');
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.ticket_ids) && data.ticket_ids.length > 0) {
|
||||||
|
return data.ticket_ids.map(tid => `<a href="#/tickets/${tid}" class="ticket-tn-link" onclick="event.stopPropagation();">#${tid}</a>`).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '—';
|
||||||
|
} catch (_) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_buildRow(r) {
|
_buildRow(r) {
|
||||||
const dt = r.creato_il ? new Date(r.creato_il).toLocaleString('it-IT') : '—';
|
const dt = r.creato_il ? new Date(r.creato_il).toLocaleString('it-IT') : '—';
|
||||||
|
const ticketRef = this._extractTicketRef(r);
|
||||||
const esitoBadge = r.esito === 'successo'
|
const esitoBadge = r.esito === 'successo'
|
||||||
? `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(34,197,94,0.15);color:#16a34a;font-size:0.75rem;font-weight:600;">✓ successo</span>`
|
? `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(34,197,94,0.15);color:#16a34a;font-size:0.75rem;font-weight:600;">✓ successo</span>`
|
||||||
: `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(239,68,68,0.15);color:#dc2626;font-size:0.75rem;font-weight:600;">✕ errore</span>`;
|
: `<span style="display:inline-block;padding:2px 10px;border-radius:12px;background:rgba(239,68,68,0.15);color:#dc2626;font-size:0.75rem;font-weight:600;">✕ errore</span>`;
|
||||||
@@ -88,13 +123,14 @@ const ActivityLogView = {
|
|||||||
<td style="font-size:0.8rem;color:var(--text-secondary);white-space:nowrap;">${dt}</td>
|
<td style="font-size:0.8rem;color:var(--text-secondary);white-space:nowrap;">${dt}</td>
|
||||||
<td style="font-size:0.85rem;">${App.escapeHtml(r.agente_nome || '—')}</td>
|
<td style="font-size:0.85rem;">${App.escapeHtml(r.agente_nome || '—')}</td>
|
||||||
<td style="font-size:0.85rem;font-weight:500;">${App.escapeHtml(r.titolo_azione)}</td>
|
<td style="font-size:0.85rem;font-weight:500;">${App.escapeHtml(r.titolo_azione)}</td>
|
||||||
|
<td style="font-size:0.85rem;">${ticketRef}</td>
|
||||||
<td style="text-align:center;">${esitoBadge}</td>
|
<td style="text-align:center;">${esitoBadge}</td>
|
||||||
<td style="text-align:center;">
|
<td style="text-align:center;">
|
||||||
<button class="btn btn-ghost btn-sm act-detail-btn" data-id="${r.id}" title="Mostra dettaglio" style="padding:4px 8px;font-size:0.85rem;">≡</button>
|
<button class="btn btn-ghost btn-sm act-detail-btn" data-id="${r.id}" title="Mostra dettaglio" style="padding:4px 8px;font-size:0.85rem;">≡</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="detail-${r.id}" class="act-detail-row" style="display:none;">
|
<tr id="detail-${r.id}" class="act-detail-row" style="display:none;">
|
||||||
<td colspan="5" style="padding:0 var(--space-md) var(--space-md);background:var(--bg-secondary);">
|
<td colspan="6" style="padding:0 var(--space-md) var(--space-md);background:var(--bg-secondary);">
|
||||||
<pre style="margin:0;padding:var(--space-md);background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:0.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-primary);border:1px solid var(--border-subtle);">${App.escapeHtml(this._prettyJson(r.azione))}</pre>
|
<pre style="margin:0;padding:var(--space-md);background:var(--bg-tertiary);border-radius:var(--radius-md);font-size:0.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-primary);border:1px solid var(--border-subtle);">${App.escapeHtml(this._prettyJson(r.azione))}</pre>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -659,6 +659,9 @@ const TicketCreateView = {
|
|||||||
// Update daily timer
|
// Update daily timer
|
||||||
App.updateDailyTimer();
|
App.updateDailyTimer();
|
||||||
|
|
||||||
|
// Invalidate list cache so newly created ticket appears in lists
|
||||||
|
TicketListView.invalidateCache();
|
||||||
|
|
||||||
// Navigate to the new ticket
|
// Navigate to the new ticket
|
||||||
window.location.hash = `#/tickets/${result.id}`;
|
window.location.hash = `#/tickets/${result.id}`;
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ const TicketDetailView = {
|
|||||||
try {
|
try {
|
||||||
await App.ensureLookups();
|
await App.ensureLookups();
|
||||||
const data = await App.api(`/api/tickets/${id}`);
|
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;
|
const { ticket, articles, attachments } = data;
|
||||||
// No automatic tab creation here anymore
|
// No automatic tab creation here anymore
|
||||||
|
|
||||||
@@ -838,6 +845,7 @@ const TicketDetailView = {
|
|||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
});
|
});
|
||||||
Toast.success(res.message || 'Ticket aggiornato!');
|
Toast.success(res.message || 'Ticket aggiornato!');
|
||||||
|
TicketListView.invalidateCache();
|
||||||
// Refresh the view
|
// Refresh the view
|
||||||
this.render(this.ticketId);
|
this.render(this.ticketId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -9,10 +9,51 @@ const TicketListView = {
|
|||||||
sortDir: 'DESC',
|
sortDir: 'DESC',
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
selectedOrder: [],
|
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');
|
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')) {
|
if (!container.querySelector('.ticket-table-wrapper') && !container.querySelector('.ticket-table')) {
|
||||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||||||
}
|
}
|
||||||
@@ -31,34 +72,28 @@ const TicketListView = {
|
|||||||
console.warn('Failed to load agent settings:', err);
|
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()}`);
|
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.renderContent(container, data);
|
||||||
this.bindEvents(data);
|
this.bindEvents(data);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const currentHash = (window.location.hash || '#/dashboard').split('?')[0];
|
||||||
|
if (currentHash !== '#/tickets' && currentHash !== '#/tickets/my') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">⚠️</div>
|
<div class="empty-state-icon">⚠️</div>
|
||||||
@@ -679,6 +714,7 @@ const TicketListView = {
|
|||||||
Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`);
|
Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`);
|
||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
this.selectedOrder = [];
|
this.selectedOrder = [];
|
||||||
|
this.invalidateCache();
|
||||||
this.render();
|
this.render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.error('Errore aggiornamento batch: ' + err.message);
|
Toast.error('Errore aggiornamento batch: ' + err.message);
|
||||||
@@ -727,6 +763,8 @@ const TicketListView = {
|
|||||||
Toast.success(res.message || 'Ticket uniti con successo');
|
Toast.success(res.message || 'Ticket uniti con successo');
|
||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
this.selectedOrder = [];
|
this.selectedOrder = [];
|
||||||
|
this.invalidateCache();
|
||||||
|
this.render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.error('Errore durante l\'unione: ' + err.message);
|
Toast.error('Errore durante l\'unione: ' + err.message);
|
||||||
this.updateBatchBar();
|
this.updateBatchBar();
|
||||||
@@ -799,6 +837,7 @@ const TicketListView = {
|
|||||||
Toast.success(`${addedCount} ticket aggiunti al gruppo!`);
|
Toast.success(`${addedCount} ticket aggiunti al gruppo!`);
|
||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
this.selectedOrder = [];
|
this.selectedOrder = [];
|
||||||
|
this.invalidateCache();
|
||||||
this.render();
|
this.render();
|
||||||
} else if (errorsCount > 0) {
|
} else if (errorsCount > 0) {
|
||||||
Toast.warning('I ticket selezionati appartengono già a questo gruppo.');
|
Toast.warning('I ticket selezionati appartengono già a questo gruppo.');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
+96
-4
@@ -754,7 +754,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
let current;
|
let current;
|
||||||
try {
|
try {
|
||||||
const currentResult = await pool.query(
|
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`,
|
FROM ticket WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
@@ -832,7 +832,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
agente_id: operatorId,
|
agente_id: operatorId,
|
||||||
agente_nome,
|
agente_nome,
|
||||||
titolo_azione: isClosing ? 'Chiusura Ticket' : 'Modifica Rapida Ticket',
|
titolo_azione: isClosing ? 'Chiusura Ticket' : 'Modifica Rapida Ticket',
|
||||||
azione: { ticket_id: id, ...updates },
|
azione: { ticket_id: id, tn: current.tn, ...updates },
|
||||||
esito: 'successo',
|
esito: 'successo',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1048,7 +1048,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
agente_id: operatorId,
|
agente_id: operatorId,
|
||||||
agente_nome,
|
agente_nome,
|
||||||
titolo_azione: isClosingDB ? 'Chiusura Ticket' : 'Modifica Rapida Ticket',
|
titolo_azione: isClosingDB ? 'Chiusura Ticket' : 'Modifica Rapida Ticket',
|
||||||
azione: { ticket_id: id, ...updates },
|
azione: { ticket_id: id, tn: current.tn, ...updates },
|
||||||
esito: 'successo',
|
esito: 'successo',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1392,7 +1392,7 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
agente_id: operatorId,
|
agente_id: operatorId,
|
||||||
agente_nome,
|
agente_nome,
|
||||||
titolo_azione: 'Aggiunta Nota',
|
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',
|
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
|
// PUT /api/articles/:articleId/time — Update time accounted for an article
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user