Compare commits
3
Commits
d39c9895b4
...
3b06609fcb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b06609fcb | ||
|
|
fd78b7e283 | ||
|
|
f9cbfc63b9 |
@@ -35,3 +35,12 @@ CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc
|
|||||||
|
|
||||||
# Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%)
|
# Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%)
|
||||||
PHRASE_THRESHOLD=70
|
PHRASE_THRESHOLD=70
|
||||||
|
|
||||||
|
# Configurazioni per la consuntivazione automatica fine giornata
|
||||||
|
AUTO_TIME_MIN_HOUR=18:00
|
||||||
|
AUTO_TIME_QUEUE=Assistenza
|
||||||
|
AUTO_TIME_TYPE=Default
|
||||||
|
AUTO_TIME_TITLE=Consuntivazione Automatica fine giornata
|
||||||
|
AUTO_TIME_SUBJECT=Consuntivazione automatica ore mancanti
|
||||||
|
AUTO_TIME_BODY=Consuntivazione eseguita automaticamente per il completamento delle ore lavorative giornaliere.
|
||||||
|
AUTO_TIME_CUSTOMER_USER=client_generic
|
||||||
@@ -27,6 +27,20 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_settings (
|
||||||
|
agent_id INTEGER PRIMARY KEY,
|
||||||
|
preview_limit INTEGER NOT NULL DEFAULT 10,
|
||||||
|
tickets_per_page INTEGER NOT NULL DEFAULT 50
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a UUID v7 (time-ordered).
|
* Generate a UUID v7 (time-ordered).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2422,3 +2422,74 @@ body {
|
|||||||
padding: 12px !important;
|
padding: 12px !important;
|
||||||
transition: all 0.5s ease;
|
transition: all 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes autotime-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
||||||
|
border-color: rgba(16, 112, 202, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 15px rgba(16, 112, 202, 0.8), inset 0 0 5px rgba(16, 112, 202, 0.3);
|
||||||
|
border-color: rgba(16, 112, 202, 0.9);
|
||||||
|
background: rgba(16, 112, 202, 0.15);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 5px rgba(16, 112, 202, 0.4);
|
||||||
|
border-color: rgba(16, 112, 202, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand.clickable-auto-time {
|
||||||
|
cursor: pointer;
|
||||||
|
animation: autotime-pulse 2.5s infinite ease-in-out;
|
||||||
|
border: 1px dashed var(--accent-primary) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--space-xs) var(--space-sm);
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand.clickable-auto-time:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-timer.clickable-auto-time {
|
||||||
|
cursor: pointer;
|
||||||
|
animation: autotime-pulse 2.5s infinite ease-in-out;
|
||||||
|
border: 1px dashed var(--accent-primary) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--space-xs) var(--space-sm);
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-timer.clickable-auto-time:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes overperformance-alarm-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 15px rgba(239, 68, 68, 0.8), inset 0 0 5px rgba(239, 68, 68, 0.3);
|
||||||
|
border-color: rgba(239, 68, 68, 0.9);
|
||||||
|
background: rgba(239, 68, 68, 0.15) !important;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 5px rgba(239, 68, 68, 0.4);
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-timer.overperformance-alarm {
|
||||||
|
animation: overperformance-alarm-pulse 1.5s infinite ease-in-out;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.6) !important;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--space-xs) var(--space-sm);
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
+12
-5
@@ -22,7 +22,8 @@
|
|||||||
<div class="brand-icon">⚡</div>
|
<div class="brand-icon">⚡</div>
|
||||||
<span class="brand-text">OTRS Turbo</span>
|
<span class="brand-text">OTRS Turbo</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-timer" id="daily-timer" style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
|
<div class="sidebar-timer" id="daily-timer"
|
||||||
|
style="padding:var(--space-md) var(--space-lg) var(--space-lg) var(--space-lg); border-bottom:1px solid var(--border-subtle); font-size:0.75rem; color:var(--text-secondary); font-family:monospace; line-height:1.2;">
|
||||||
<span class="timer-display">0 / 480 | 480</span>
|
<span class="timer-display">0 / 480 | 480</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<polyline points="12 6 12 12 16 14" />
|
<polyline points="12 6 12 12 16 14" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Storico Attività</span>
|
<span>Storico Attività Turbo</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -131,9 +132,15 @@
|
|||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="M21 21l-4.35-4.35" />
|
<path d="M21 21l-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" class="search-input" id="global-search" placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
|
<input type="text" class="search-input" id="global-search"
|
||||||
<button id="global-search-clear" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-muted); display: none; align-items: center; justify-content: center;" title="Cancella ricerca">
|
placeholder="Cerca ticket (numero, titolo o corpo)..." style="padding-right: 32px;" />
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;display:block;"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
<button id="global-search-clear"
|
||||||
|
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-muted); display: none; align-items: center; justify-content: center;"
|
||||||
|
title="Cancella ricerca">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
style="width:14px;height:14px;display:block;">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
|
<button class="btn btn-primary btn-sm" id="topbar-new-ticket" onclick="window.location.hash='#/tickets/new'">
|
||||||
|
|||||||
+69
-1
@@ -186,10 +186,13 @@ const App = {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
this.lookups = JSON.parse(cached);
|
this.lookups = JSON.parse(cached);
|
||||||
|
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined) {
|
||||||
|
throw new Error('Outdated config cache (missing autoTimeMinHour)');
|
||||||
|
}
|
||||||
this.lookupsLoaded = true;
|
this.lookupsLoaded = true;
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to parse cached lookups, reloading...', e);
|
console.warn('Failed to parse cached lookups, reloading...', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,6 +439,71 @@ const App = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (timerEl) {
|
||||||
|
if (hasOverperformance) {
|
||||||
|
timerEl.classList.add('overperformance-alarm');
|
||||||
|
} else {
|
||||||
|
timerEl.classList.remove('overperformance-alarm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minTimeStr = this.lookups.config?.autoTimeMinHour || '18:00';
|
||||||
|
const [minHour, minMin] = minTimeStr.split(':').map(x => parseInt(x, 10));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
const currentMin = now.getMinutes();
|
||||||
|
|
||||||
|
let isPastTime = false;
|
||||||
|
if (currentHour > minHour) {
|
||||||
|
isPastTime = true;
|
||||||
|
} else if (currentHour === minHour && currentMin >= minMin) {
|
||||||
|
isPastTime = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerAction = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const confirmed = confirm(`Sei sicuro di voler effettuare la consuntivazione automatica di ${remaining} minuti rimanenti di oggi? Verrà creato un ticket chiuso a tuo carico.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.success('Consuntivazione in corso...');
|
||||||
|
const res = await this.api('/api/tickets/auto-time', { method: 'POST' });
|
||||||
|
Toast.success(res.message || 'Consuntivazione completata!');
|
||||||
|
this.updateDailyTimer();
|
||||||
|
this.route();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore consuntivazione automatica: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isPastTime && remaining > 0) {
|
||||||
|
if (brandEl) {
|
||||||
|
brandEl.classList.add('clickable-auto-time');
|
||||||
|
brandEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m)`);
|
||||||
|
brandEl.onclick = triggerAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timerEl) {
|
||||||
|
timerEl.classList.add('clickable-auto-time');
|
||||||
|
timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`);
|
||||||
|
timerEl.onclick = triggerAction;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (brandEl) {
|
||||||
|
brandEl.classList.remove('clickable-auto-time');
|
||||||
|
brandEl.removeAttribute('title');
|
||||||
|
brandEl.onclick = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timerEl) {
|
||||||
|
timerEl.classList.remove('clickable-auto-time');
|
||||||
|
timerEl.removeAttribute('title');
|
||||||
|
timerEl.onclick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let phrase = "";
|
let phrase = "";
|
||||||
const phraseThreshold = this.lookups.config?.phraseThreshold || 70;
|
const phraseThreshold = this.lookups.config?.phraseThreshold || 70;
|
||||||
const isDemotivational = percentage >= phraseThreshold;
|
const isDemotivational = percentage >= phraseThreshold;
|
||||||
|
|||||||
@@ -88,7 +88,19 @@ const DashboardView = {
|
|||||||
|
|
||||||
<!-- Recent Tickets -->
|
<!-- Recent Tickets -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">Ticket Recenti</div>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md); flex-wrap:wrap; gap:var(--space-xs);">
|
||||||
|
<div class="card-title" style="margin-bottom:0;">Ticket Recenti</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:var(--space-xs); font-size:0.85rem; color:var(--text-secondary);">
|
||||||
|
<span>Mostra:</span>
|
||||||
|
<select id="dashboard-preview-limit" class="form-select" style="padding: 4px 28px 4px 8px; font-size: 0.8rem; height: 28px; min-width: 70px; margin: 0; background-position: right 8px center; border-color: var(--border-light);">
|
||||||
|
<option value="5" ${stats.preview_limit === 5 ? 'selected' : ''}>5</option>
|
||||||
|
<option value="10" ${stats.preview_limit === 10 ? 'selected' : ''}>10</option>
|
||||||
|
<option value="20" ${stats.preview_limit === 20 ? 'selected' : ''}>20</option>
|
||||||
|
<option value="50" ${stats.preview_limit === 50 ? 'selected' : ''}>50</option>
|
||||||
|
<option value="100" ${stats.preview_limit === 100 ? 'selected' : ''}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
${(stats.recent_tickets || []).length > 0 ? `
|
${(stats.recent_tickets || []).length > 0 ? `
|
||||||
<table class="recent-tickets-table">
|
<table class="recent-tickets-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -132,6 +144,24 @@ const DashboardView = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bind preview limit change event
|
||||||
|
const limitSelect = document.getElementById('dashboard-preview-limit');
|
||||||
|
if (limitSelect) {
|
||||||
|
limitSelect.addEventListener('change', async () => {
|
||||||
|
const newLimit = parseInt(limitSelect.value, 10);
|
||||||
|
try {
|
||||||
|
await App.api('/api/dashboard/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ preview_limit: newLimit }),
|
||||||
|
});
|
||||||
|
Toast.success(`Limite anteprima aggiornato a ${newLimit} ticket!`);
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante il salvataggio dell\'impostazione: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update open ticket count in sidebar badge
|
// Update open ticket count in sidebar badge
|
||||||
const badge = document.getElementById('open-ticket-count');
|
const badge = document.getElementById('open-ticket-count');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
|
|||||||
@@ -235,7 +235,17 @@ const TicketCreateView = {
|
|||||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
['clean']
|
['clean']
|
||||||
]
|
],
|
||||||
|
keyboard: {
|
||||||
|
bindings: {
|
||||||
|
tab: {
|
||||||
|
key: 'Tab',
|
||||||
|
handler: function() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -383,7 +383,17 @@ const TicketDetailView = {
|
|||||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
['clean']
|
['clean']
|
||||||
]
|
],
|
||||||
|
keyboard: {
|
||||||
|
bindings: {
|
||||||
|
tab: {
|
||||||
|
key: 'Tab',
|
||||||
|
handler: function() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ const TicketListView = {
|
|||||||
// Fetch lookups for filter dropdowns
|
// Fetch lookups for filter dropdowns
|
||||||
await App.ensureLookups();
|
await App.ensureLookups();
|
||||||
|
|
||||||
|
// Fetch agent settings for tickets_per_page
|
||||||
|
try {
|
||||||
|
const settings = await App.api('/api/dashboard/settings');
|
||||||
|
if (settings && settings.tickets_per_page) {
|
||||||
|
this.perPage = settings.tickets_per_page;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load agent settings:', err);
|
||||||
|
}
|
||||||
|
|
||||||
// Build query params
|
// Build query params
|
||||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||||
Filters.currentMode = isMyTickets ? 'my' : 'general';
|
Filters.currentMode = isMyTickets ? 'my' : 'general';
|
||||||
@@ -168,6 +178,19 @@ const TicketListView = {
|
|||||||
|
|
||||||
renderPagination(page, per_page, total, total_pages, isTop) {
|
renderPagination(page, per_page, total, total_pages, isTop) {
|
||||||
const marginStyle = isTop ? 'margin-bottom: var(--space-md); margin-top: 0;' : 'margin-top: var(--space-md); margin-bottom: 0;';
|
const marginStyle = isTop ? 'margin-bottom: var(--space-md); margin-top: 0;' : 'margin-top: var(--space-md); margin-bottom: 0;';
|
||||||
|
const limitSelectHtml = `
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:var(--space-xs); font-size:0.8rem; color:var(--text-secondary); margin-right:var(--space-md);">
|
||||||
|
<span>Righe:</span>
|
||||||
|
<select class="form-select ticket-per-page-select" style="padding: 2px 24px 2px 6px; font-size: 0.75rem; height: 26px; min-width: 65px; margin: 0; background-position: right 6px center; border-color: var(--border-light);">
|
||||||
|
<option value="10" ${per_page === 10 ? 'selected' : ''}>10</option>
|
||||||
|
<option value="20" ${per_page === 20 ? 'selected' : ''}>20</option>
|
||||||
|
<option value="50" ${per_page === 50 ? 'selected' : ''}>50</option>
|
||||||
|
<option value="100" ${per_page === 100 ? 'selected' : ''}>100</option>
|
||||||
|
<option value="200" ${per_page === 200 ? 'selected' : ''}>200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
if (total_pages > 1) {
|
if (total_pages > 1) {
|
||||||
return `
|
return `
|
||||||
<div class="pagination" style="${marginStyle}">
|
<div class="pagination" style="${marginStyle}">
|
||||||
@@ -175,6 +198,7 @@ const TicketListView = {
|
|||||||
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination-controls">
|
<div class="pagination-controls">
|
||||||
|
${limitSelectHtml}
|
||||||
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
|
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
|
||||||
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>‹</button>
|
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>‹</button>
|
||||||
${this.renderPageButtons(page, total_pages)}
|
${this.renderPageButtons(page, total_pages)}
|
||||||
@@ -187,7 +211,9 @@ const TicketListView = {
|
|||||||
return `
|
return `
|
||||||
<div class="pagination" style="${marginStyle}">
|
<div class="pagination" style="${marginStyle}">
|
||||||
<div class="pagination-info">${total} ticket totali</div>
|
<div class="pagination-info">${total} ticket totali</div>
|
||||||
<div></div>
|
<div class="pagination-controls">
|
||||||
|
${limitSelectHtml}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -400,6 +426,25 @@ const TicketListView = {
|
|||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Page size change
|
||||||
|
document.querySelectorAll('.ticket-per-page-select').forEach(select => {
|
||||||
|
select.addEventListener('change', async () => {
|
||||||
|
const newLimit = parseInt(select.value, 10);
|
||||||
|
try {
|
||||||
|
await App.api('/api/dashboard/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tickets_per_page: newLimit }),
|
||||||
|
});
|
||||||
|
Toast.success(`Righe per pagina aggiornate a ${newLimit}!`);
|
||||||
|
this.perPage = newLimit;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante il salvataggio dell\'impostazione: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateBatchBar() {
|
updateBatchBar() {
|
||||||
|
|||||||
+59
-3
@@ -74,6 +74,17 @@ seedPhrases();
|
|||||||
// GET /api/dashboard/stats — Dashboard statistics
|
// GET /api/dashboard/stats — Dashboard statistics
|
||||||
router.get('/stats', async (req, res) => {
|
router.get('/stats', async (req, res) => {
|
||||||
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
let previewLimit = 10;
|
||||||
|
try {
|
||||||
|
const row = db.prepare("SELECT preview_limit FROM agent_settings WHERE agent_id = ?").get(activeAgentId);
|
||||||
|
if (row) {
|
||||||
|
previewLimit = row.preview_limit;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading agent_settings:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// All queries in parallel for speed
|
// All queries in parallel for speed
|
||||||
const [
|
const [
|
||||||
@@ -138,7 +149,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')`
|
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')`
|
||||||
),
|
),
|
||||||
// 10 most recent tickets
|
// Custom most recent tickets based on agent settings
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT t.id, t.tn, t.title, ts.name AS state_name,
|
`SELECT t.id, t.tn, t.title, ts.name AS state_name,
|
||||||
tp.name AS priority_name, tp.color AS priority_color,
|
tp.name AS priority_name, tp.color AS priority_color,
|
||||||
@@ -148,7 +159,8 @@ router.get('/stats', async (req, res) => {
|
|||||||
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
|
JOIN ticket_priority tp ON t.ticket_priority_id = tp.id
|
||||||
JOIN queue q ON t.queue_id = q.id
|
JOIN queue q ON t.queue_id = q.id
|
||||||
ORDER BY t.create_time DESC
|
ORDER BY t.create_time DESC
|
||||||
LIMIT 10`
|
LIMIT $1`,
|
||||||
|
[previewLimit]
|
||||||
),
|
),
|
||||||
// Escalated tickets
|
// Escalated tickets
|
||||||
pool.query(
|
pool.query(
|
||||||
@@ -164,7 +176,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||||
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
|
WHERE tst.name IN ('new', 'open', 'pending reminder', 'pending auto')
|
||||||
AND t.user_id = $1`,
|
AND t.user_id = $1`,
|
||||||
[activeAgentId]
|
[activeAgentId]
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -178,6 +190,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
recent_tickets: recentTickets.rows,
|
recent_tickets: recentTickets.rows,
|
||||||
escalated: parseInt(escalated.rows[0].count),
|
escalated: parseInt(escalated.rows[0].count),
|
||||||
total_my_open: parseInt(myOpenCount.rows[0].count),
|
total_my_open: parseInt(myOpenCount.rows[0].count),
|
||||||
|
preview_limit: previewLimit,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching dashboard stats:', err);
|
console.error('Error fetching dashboard stats:', err);
|
||||||
@@ -206,4 +219,47 @@ router.get('/phrases', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/settings — Retrieve settings for the agent
|
||||||
|
router.get('/settings', (req, res) => {
|
||||||
|
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
try {
|
||||||
|
const row = db.prepare("SELECT preview_limit, tickets_per_page FROM agent_settings WHERE agent_id = ?").get(activeAgentId);
|
||||||
|
if (row) {
|
||||||
|
res.json(row);
|
||||||
|
} else {
|
||||||
|
res.json({ preview_limit: 10, tickets_per_page: 50 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading agent settings:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/dashboard/settings — Save settings for the agent
|
||||||
|
router.post('/settings', (req, res) => {
|
||||||
|
const activeAgentId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
const { preview_limit, tickets_per_page } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let currentSettings = { preview_limit: 10, tickets_per_page: 50 };
|
||||||
|
const row = db.prepare("SELECT preview_limit, tickets_per_page FROM agent_settings WHERE agent_id = ?").get(activeAgentId);
|
||||||
|
if (row) {
|
||||||
|
currentSettings = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPreviewLimit = preview_limit !== undefined ? parseInt(preview_limit, 10) : currentSettings.preview_limit;
|
||||||
|
const newTicketsPerPage = tickets_per_page !== undefined ? parseInt(tickets_per_page, 10) : currentSettings.tickets_per_page;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO agent_settings (agent_id, preview_limit, tickets_per_page)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(activeAgentId, newPreviewLimit, newTicketsPerPage);
|
||||||
|
|
||||||
|
res.json({ success: true, preview_limit: newPreviewLimit, tickets_per_page: newTicketsPerPage });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving agent settings:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+2
-1
@@ -136,7 +136,8 @@ router.get('/config', (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
defaultAgentLogin: process.env.OTRS_API_USER || '',
|
defaultAgentLogin: process.env.OTRS_API_USER || '',
|
||||||
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480,
|
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480,
|
||||||
phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70
|
phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70,
|
||||||
|
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1685,4 +1685,291 @@ router.put('/articles/:articleId/time', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/tickets/auto-time — Automatic end-of-day time accounting ticket
|
||||||
|
// ============================================================
|
||||||
|
router.post('/auto-time', async (req, res) => {
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
// Verify time
|
||||||
|
const minTimeStr = process.env.AUTO_TIME_MIN_HOUR || '18:00';
|
||||||
|
const [minHour, minMin] = minTimeStr.split(':').map(x => parseInt(x, 10));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
const currentMin = now.getMinutes();
|
||||||
|
|
||||||
|
let isPastTime = false;
|
||||||
|
if (currentHour > minHour) {
|
||||||
|
isPastTime = true;
|
||||||
|
} else if (currentHour === minHour && currentMin >= minMin) {
|
||||||
|
isPastTime = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPastTime) {
|
||||||
|
return res.status(400).json({ error: `La consuntivazione automatica è consentita solo dopo le ore ${minTimeStr}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 1. Calculate remaining time
|
||||||
|
const targetTime = parseInt(process.env.DAILY_TARGET_TIME, 10) || 480;
|
||||||
|
const timeTodayRes = await client.query(
|
||||||
|
`SELECT COALESCE(SUM(time_unit), 0) AS total_today
|
||||||
|
FROM time_accounting
|
||||||
|
WHERE create_by = $1 AND DATE(create_time) = CURRENT_DATE`,
|
||||||
|
[operatorId]
|
||||||
|
);
|
||||||
|
const totalToday = parseFloat(timeTodayRes.rows[0].total_today);
|
||||||
|
const remaining = Math.max(0, targetTime - totalToday);
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'La soglia giornaliera è già stata raggiunta o superata' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Resolve parameters from configuration / OTRS DB
|
||||||
|
const queueName = process.env.AUTO_TIME_QUEUE || 'Assistenza';
|
||||||
|
const typeName = process.env.AUTO_TIME_TYPE || 'Default';
|
||||||
|
const title = process.env.AUTO_TIME_TITLE || 'Consuntivazione Automatica fine giornata';
|
||||||
|
const subject = process.env.AUTO_TIME_SUBJECT || 'Consuntivazione automatica ore mancanti';
|
||||||
|
const body = process.env.AUTO_TIME_BODY || 'Consuntivazione automatica fine giornata.';
|
||||||
|
const configCustomerUser = process.env.AUTO_TIME_CUSTOMER_USER;
|
||||||
|
|
||||||
|
// Resolve agent login for owner / customer fallback
|
||||||
|
const agentRes = await client.query(
|
||||||
|
`SELECT login, first_name, last_name FROM users WHERE id = $1`,
|
||||||
|
[operatorId]
|
||||||
|
);
|
||||||
|
if (agentRes.rows.length === 0) {
|
||||||
|
throw new Error(`Impossibile trovare l'agente attivo con ID #${operatorId}`);
|
||||||
|
}
|
||||||
|
const agentLogin = agentRes.rows[0].login;
|
||||||
|
const customerUserId = configCustomerUser || agentLogin;
|
||||||
|
|
||||||
|
// Resolve CustomerID
|
||||||
|
let customerId = 'Self-Service';
|
||||||
|
const custRes = await client.query(
|
||||||
|
`SELECT customer_id FROM customer_user WHERE login = $1`,
|
||||||
|
[customerUserId]
|
||||||
|
);
|
||||||
|
if (custRes.rows.length > 0) {
|
||||||
|
customerId = custRes.rows[0].customer_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve state ID (closed successful / closed fallback)
|
||||||
|
const stateRes = await client.query(
|
||||||
|
`SELECT id FROM ticket_state WHERE name = 'closed successful' OR name = 'chiuso con successo' LIMIT 1`
|
||||||
|
);
|
||||||
|
let stateId = stateRes.rows[0]?.id;
|
||||||
|
if (!stateId) {
|
||||||
|
const stateFallback = await client.query(
|
||||||
|
`SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'closed' LIMIT 1`
|
||||||
|
);
|
||||||
|
stateId = stateFallback.rows[0]?.id || 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve queue ID
|
||||||
|
const queueRes = await client.query(
|
||||||
|
`SELECT id FROM queue WHERE name = $1 LIMIT 1`,
|
||||||
|
[queueName]
|
||||||
|
);
|
||||||
|
const queueId = queueRes.rows[0]?.id || 1;
|
||||||
|
|
||||||
|
// Resolve type ID
|
||||||
|
const typeRes = await client.query(
|
||||||
|
`SELECT id FROM ticket_type WHERE name = $1 LIMIT 1`,
|
||||||
|
[typeName]
|
||||||
|
);
|
||||||
|
const typeId = typeRes.rows[0]?.id || null;
|
||||||
|
|
||||||
|
// Resolve priority ID (3 normal)
|
||||||
|
const priorityRes = await client.query(
|
||||||
|
`SELECT id FROM ticket_priority WHERE name = '3 normal' LIMIT 1`
|
||||||
|
);
|
||||||
|
const priorityId = priorityRes.rows[0]?.id || 3;
|
||||||
|
|
||||||
|
// Generate ticket number (daily reset)
|
||||||
|
const now = new Date();
|
||||||
|
const systemId = process.env.OTRS_SYSTEM_ID || '10';
|
||||||
|
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
|
||||||
|
|
||||||
|
const maxTicketResult = await client.query(
|
||||||
|
`SELECT tn FROM ticket WHERE tn LIKE $1`,
|
||||||
|
[`${datePrefix}%`]
|
||||||
|
);
|
||||||
|
let maxCounterFromTicketTable = 0;
|
||||||
|
for (const row of maxTicketResult.rows) {
|
||||||
|
const counterPart = row.tn.substring(datePrefix.length);
|
||||||
|
const parsed = parseInt(counterPart, 10);
|
||||||
|
if (!isNaN(parsed) && parsed > maxCounterFromTicketTable) {
|
||||||
|
maxCounterFromTicketTable = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counterTodayResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(counter), 0) AS max_counter
|
||||||
|
FROM ticket_number_counter
|
||||||
|
WHERE create_time >= CURRENT_DATE`
|
||||||
|
);
|
||||||
|
const maxCounterFromCounterTable = parseInt(counterTodayResult.rows[0].max_counter, 10);
|
||||||
|
const nextCounter = Math.max(maxCounterFromTicketTable, maxCounterFromCounterTable) + 1;
|
||||||
|
|
||||||
|
const counterResult = await client.query(
|
||||||
|
`INSERT INTO ticket_number_counter (counter, counter_uid, create_time)
|
||||||
|
VALUES ($1, md5(random()::text || clock_timestamp()::text), NOW())
|
||||||
|
RETURNING counter`,
|
||||||
|
[nextCounter]
|
||||||
|
);
|
||||||
|
const counter = counterResult.rows[0].counter;
|
||||||
|
const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6;
|
||||||
|
const tn = `${datePrefix}${String(counter).padStart(counterPadding, '0')}`;
|
||||||
|
|
||||||
|
// 3. Create Ticket (lock = 1 [unlock], owner & responsible = operatorId)
|
||||||
|
const ticketResult = await client.query(
|
||||||
|
`INSERT INTO ticket (
|
||||||
|
tn, title, queue_id, ticket_lock_id, type_id,
|
||||||
|
user_id, responsible_user_id,
|
||||||
|
ticket_priority_id, ticket_state_id,
|
||||||
|
customer_id, customer_user_id,
|
||||||
|
timeout, until_time,
|
||||||
|
escalation_time, escalation_update_time,
|
||||||
|
escalation_response_time, escalation_solution_time,
|
||||||
|
archive_flag,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, 1, $4,
|
||||||
|
$5, $5,
|
||||||
|
$6, $7,
|
||||||
|
$8, $9,
|
||||||
|
0, 0,
|
||||||
|
0, 0,
|
||||||
|
0, 0,
|
||||||
|
0,
|
||||||
|
NOW(), $10, NOW(), $10
|
||||||
|
) RETURNING id, tn`,
|
||||||
|
[
|
||||||
|
tn, title, queueId, typeId,
|
||||||
|
operatorId,
|
||||||
|
priorityId, stateId,
|
||||||
|
customerId, customerUserId,
|
||||||
|
operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const ticketId = ticketResult.rows[0].id;
|
||||||
|
|
||||||
|
// Ticket History for NewTicket
|
||||||
|
const htResult = await client.query(
|
||||||
|
`SELECT id FROM ticket_history_type WHERE name = 'NewTicket'`
|
||||||
|
);
|
||||||
|
const historyTypeId = htResult.rows.length > 0 ? htResult.rows[0].id : 1;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO ticket_history (
|
||||||
|
name, history_type_id, ticket_id, type_id, queue_id,
|
||||||
|
owner_id, priority_id, state_id,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, NOW(), $9)`,
|
||||||
|
['%%', historyTypeId, ticketId, typeId || 1, queueId, operatorId, priorityId, stateId, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Create Article (Internal note)
|
||||||
|
const senderResult = await client.query(
|
||||||
|
`SELECT id FROM article_sender_type WHERE name = 'agent'`
|
||||||
|
);
|
||||||
|
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
const channelResult = await client.query(
|
||||||
|
`SELECT id FROM communication_channel WHERE name = 'Internal'`
|
||||||
|
);
|
||||||
|
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
const articleResult = await client.query(
|
||||||
|
`INSERT INTO article (
|
||||||
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, $2, $3, 0, 1, NOW(), $4, NOW(), $4) RETURNING id`,
|
||||||
|
[ticketId, senderTypeId, channelId, operatorId]
|
||||||
|
);
|
||||||
|
const articleId = articleResult.rows[0].id;
|
||||||
|
|
||||||
|
// Create article mime data
|
||||||
|
const nowMime = new Date();
|
||||||
|
const contentPath = `${nowMime.getFullYear()}/${String(nowMime.getMonth() + 1).padStart(2, '0')}/${String(nowMime.getDate()).padStart(2, '0')}`;
|
||||||
|
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body} (Mancanti: ${remaining} m)</body></html>`;
|
||||||
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
||||||
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
||||||
|
const base64Body = binaryBody.toString('base64');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime (
|
||||||
|
article_id, a_from, a_to, a_subject, a_body,
|
||||||
|
a_content_type, incoming_time, content_path,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, 'OTRS Turbo Agent', '', $2, $3,
|
||||||
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $4,
|
||||||
|
NOW(), $5, NOW(), $5
|
||||||
|
)`,
|
||||||
|
[articleId, subject, htmlBody, contentPath, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create article attachment (file-1) for OTRS CE HTML display
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime_attachment (
|
||||||
|
article_id, filename, content_size, content_type, disposition, content,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3, NOW(), $4, NOW(), $4)`,
|
||||||
|
[articleId, String(contentSize), base64Body, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Create Time Accounting Entry for remaining minutes
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO time_accounting (
|
||||||
|
ticket_id, article_id, time_unit,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
|
||||||
|
[ticketId, articleId, remaining, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome,
|
||||||
|
titolo_azione: 'Consuntivazione Automatica fine giornata',
|
||||||
|
azione: { ticket_id: ticketId, tn, minutes: remaining },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: ticketId,
|
||||||
|
tn,
|
||||||
|
minutes: remaining,
|
||||||
|
message: 'Consuntivazione automatica completata con successo!'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error in auto-time sheet creation:', err);
|
||||||
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome,
|
||||||
|
titolo_azione: 'Consuntivazione Automatica fine giornata',
|
||||||
|
azione: { error: err.message },
|
||||||
|
esito: 'errore'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user