diff --git a/public/index.html b/public/index.html
index 4bfda17..fe7c192 100644
--- a/public/index.html
+++ b/public/index.html
@@ -88,7 +88,7 @@
- Note Temporanee
+ Note
diff --git a/public/js/app.js b/public/js/app.js
index 79281df..64fae29 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -313,7 +313,7 @@ const App = {
} else if (hash === '#/note-temporanee') {
document.getElementById('nav-note-temporanee')?.classList.add('active');
- titleEl.textContent = 'Note Temporanee';
+ titleEl.textContent = 'Note';
NoteTemporaneeView.render();
} else if (hash === '#/tickets/new') {
@@ -897,7 +897,7 @@ const App = {
π
-
Ricerca Spudorata Testuale
+
Ricerca Testuale Spudorata
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano istantaneamente in cache locale senza fare nuove richieste al server.
diff --git a/public/js/views/ticketCreate.js b/public/js/views/ticketCreate.js
index af59c25..8503ad9 100644
--- a/public/js/views/ticketCreate.js
+++ b/public/js/views/ticketCreate.js
@@ -462,10 +462,11 @@ const TicketCreateView = {
`).join('');
ownerSuggestionsDiv.style.display = 'block';
- // Bind click
+ // Bind click/mousedown
ownerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
- item.addEventListener('click', () => {
+ item.addEventListener('mousedown', (e) => {
+ e.preventDefault();
ownerSearchInput.value = item.dataset.name;
ownerIdInput.value = item.dataset.id;
ownerSuggestionsDiv.style.display = 'none';
@@ -517,10 +518,11 @@ const TicketCreateView = {
`).join('');
responsibleSuggestionsDiv.style.display = 'block';
- // Bind click
+ // Bind click/mousedown
responsibleSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
- item.addEventListener('click', () => {
+ item.addEventListener('mousedown', (e) => {
+ e.preventDefault();
responsibleSearchInput.value = item.dataset.name;
responsibleIdInput.value = item.dataset.id;
responsibleSuggestionsDiv.style.display = 'none';
@@ -571,10 +573,11 @@ const TicketCreateView = {
}).join('');
queueSuggestionsDiv.style.display = 'block';
- // Bind click
+ // Bind click/mousedown
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
- item.addEventListener('click', () => {
+ item.addEventListener('mousedown', (e) => {
+ e.preventDefault();
queueSearchInput.value = item.dataset.name;
queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js
index 5fcbc2f..5f71c8f 100644
--- a/public/js/views/ticketDetail.js
+++ b/public/js/views/ticketDetail.js
@@ -245,6 +245,10 @@ const TicketDetailView = {
Tempo (minuti)
+
+ Oggetto Nota (opzionale)
+
+
Nota / Commento
@@ -599,6 +603,7 @@ const TicketDetailView = {
const saveBtn = document.getElementById('qe-save');
const resetBtn = document.getElementById('qe-reset');
const timeUnitInput = document.getElementById('qe-time-unit');
+ const noteSubjectInput = document.getElementById('qe-note-subject');
const noteInput = document.getElementById('qe-note');
const checkChanges = () => {
@@ -622,6 +627,14 @@ const TicketDetailView = {
hasChanges = true;
}
+ const noteSubText = noteSubjectInput ? noteSubjectInput.value.trim() : '';
+ if (noteSubjectInput) {
+ noteSubjectInput.classList.toggle('changed', noteSubText.length > 0);
+ }
+ if (noteSubText.length > 0) {
+ hasChanges = true;
+ }
+
const noteText = noteInput ? noteInput.value.trim() : '';
if (noteInput) {
noteInput.classList.toggle('changed', noteText.length > 0);
@@ -633,11 +646,17 @@ const TicketDetailView = {
saveBtn.disabled = !hasChanges;
};
- fields.forEach(el => el.addEventListener('change', checkChanges));
+ fields.forEach(el => {
+ el.addEventListener('change', checkChanges);
+ });
if (timeUnitInput) {
timeUnitInput.addEventListener('input', checkChanges);
timeUnitInput.addEventListener('change', checkChanges);
}
+ if (noteSubjectInput) {
+ noteSubjectInput.addEventListener('input', checkChanges);
+ noteSubjectInput.addEventListener('change', checkChanges);
+ }
if (noteInput) {
noteInput.addEventListener('input', checkChanges);
noteInput.addEventListener('change', checkChanges);
@@ -796,6 +815,10 @@ const TicketDetailView = {
if (timeUnitInput) {
timeUnitInput.value = '';
}
+ if (noteSubjectInput) {
+ noteSubjectInput.value = '';
+ noteSubjectInput.classList.remove('changed');
+ }
if (noteInput) {
noteInput.value = '';
noteInput.classList.remove('changed');
@@ -840,6 +863,10 @@ const TicketDetailView = {
}
}
+ if (noteSubjectInput && noteSubjectInput.value.trim()) {
+ updates.note_subject = noteSubjectInput.value.trim();
+ }
+
if (noteInput && noteInput.value.trim()) {
updates.note = noteInput.value.trim();
}
diff --git a/public/js/views/ticketGroups.js b/public/js/views/ticketGroups.js
index bef1c2a..6cf02c0 100644
--- a/public/js/views/ticketGroups.js
+++ b/public/js/views/ticketGroups.js
@@ -13,7 +13,16 @@ const TicketGroupsView = {
const container = document.getElementById('view-container');
container.innerHTML = `
-
+
+
+
+
π
+
Nessun gruppo selezionato
+
Seleziona un gruppo dall'elenco o creane uno nuovo per iniziare a gestire le relazioni master/slave.
+
+
+
+
-
-
-
-
-
π
-
Nessun gruppo selezionato
-
Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.
-
-
@@ -89,10 +89,31 @@ const TicketGroupsView = {
masterInput.value = prefillMasterTn;
}
} else if (this.selectedGroupId) {
- this.selectGroup(this.selectedGroupId);
+ const exists = this.groups.some(g => g.id === this.selectedGroupId);
+ if (exists) {
+ this.selectGroup(this.selectedGroupId);
+ } else {
+ this.selectedGroupId = null;
+ localStorage.removeItem('otrs_selected_group_id');
+ this.renderEmptyDetailsPane();
+ }
+ } else {
+ this.renderEmptyDetailsPane();
}
},
+ renderEmptyDetailsPane() {
+ const detailPane = document.getElementById('group-details-pane');
+ if (!detailPane) return;
+ detailPane.innerHTML = `
+
+
π
+
Nessun gruppo selezionato
+
Seleziona un gruppo dalla barra laterale o creane uno nuovo per iniziare a gestire le relazioni master/slave.
+
+ `;
+ },
+
async loadGroups() {
try {
this.groups = await App.api('/api/groups');
@@ -166,12 +187,11 @@ const TicketGroupsView = {
this.selectedGroupData = await App.api(`/api/groups/${id}`);
this.renderGroupDetails();
} catch (err) {
- Toast.error('Errore nel caricamento del dettaglio gruppo: ' + err.message);
- detailPane.innerHTML = `
-
- Errore nel caricamento dei dati: ${err.message}
-
- `;
+ console.warn(`[TicketGroups] Failed to load group ${id}:`, err.message);
+ this.selectedGroupId = null;
+ localStorage.removeItem('otrs_selected_group_id');
+ this.renderGroupsList();
+ this.renderEmptyDetailsPane();
}
},
diff --git a/routes/notes.js b/routes/notes.js
index 409edd9..d8003e7 100644
--- a/routes/notes.js
+++ b/routes/notes.js
@@ -135,13 +135,12 @@ router.post('/:id/submit', async (req, res) => {
return res.status(404).json({ error: 'Nota temporanea non trovata' });
}
- if (!note.ticket_id && !note.ticket_tn) {
- return res.status(400).json({ error: 'Impossibile inviare a OTRS: nessun ticket (TN/ID) associato a questa nota' });
- }
-
- const resolved = await resolveTicketInfo(note.ticket_id, note.ticket_tn);
- if (!resolved) {
- return res.status(400).json({ error: `Ticket non trovato nel sistema OTRS (TN: ${note.ticket_tn || ''}, ID: ${note.ticket_id || ''})` });
+ let resolved = null;
+ if (note.ticket_id || note.ticket_tn) {
+ resolved = await resolveTicketInfo(note.ticket_id, note.ticket_tn);
+ if (!resolved) {
+ return res.status(400).json({ error: `Ticket non trovato nel sistema OTRS (TN: ${note.ticket_tn || ''}, ID: ${note.ticket_id || ''})` });
+ }
}
// Get agent login user
@@ -156,6 +155,81 @@ router.post('/:id/submit', async (req, res) => {
}
} catch (_) {}
+ // If no ticket is associated, create a new ticket from this note
+ if (!resolved) {
+ // Default queue (first queue or 1)
+ let queueId = 1;
+ const qRes = await pool.query('SELECT id FROM queue ORDER BY id ASC LIMIT 1');
+ if (qRes.rows.length > 0) queueId = qRes.rows[0].id;
+
+ // Default state ('new' / 1)
+ let stateId = 1;
+ const sRes = await pool.query(`SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'new' LIMIT 1`);
+ if (sRes.rows.length > 0) stateId = sRes.rows[0].id;
+
+ const ticketTitle = note.subject || 'Nuova Nota Temporanea';
+
+ // Generate TN
+ const now = new Date();
+ const systemId = process.env.OTRS_SYSTEM_ID || String(agentId);
+ const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
+
+ const maxTicketResult = await pool.query(
+ `SELECT tn FROM ticket WHERE tn LIKE $1`,
+ [`${datePrefix}%`]
+ );
+ let maxCounter = 0;
+ for (const row of maxTicketResult.rows) {
+ const counterPart = row.tn.substring(datePrefix.length);
+ const parsed = parseInt(counterPart, 10);
+ if (!isNaN(parsed) && parsed > maxCounter) {
+ maxCounter = parsed;
+ }
+ }
+ const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6;
+ const newTn = `${datePrefix}${String(maxCounter + 1).padStart(counterPadding, '0')}`;
+
+ // Insert new ticket into DB
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ const nowTs = new Date().toISOString().replace('T', ' ').substring(0, 19);
+
+ const newTicketRes = 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,
+ create_time, create_by, change_time, change_by
+ ) VALUES (
+ $1, $2, $3, 1, 1,
+ $4, $4, 3, $5,
+ $6, $4, $6, $4
+ ) RETURNING id, tn`,
+ [newTn, ticketTitle, queueId, agentId, stateId, nowTs]
+ );
+
+ const newTicketId = newTicketRes.rows[0].id;
+ resolved = { id: newTicketId, tn: newTn, title: ticketTitle };
+
+ // Insert NewTicket history
+ const htRes = await client.query(`SELECT id FROM ticket_history_type WHERE name = 'NewTicket'`);
+ const htId = htRes.rows.length > 0 ? htRes.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, 1, $3, $4, 3, $5, $6, $4, $6, $4)`,
+ [htId, newTicketId, queueId, agentId, stateId, nowTs]
+ );
+
+ await client.query('COMMIT');
+ } catch (createErr) {
+ await client.query('ROLLBACK');
+ throw createErr;
+ } finally {
+ client.release();
+ }
+ }
+
// Prepare Article for OTRS REST API
const isInternal = note.note_type === 'note-internal';
const isEmail = note.note_type === 'email-external';
@@ -195,13 +269,13 @@ router.post('/:id/submit', async (req, res) => {
}
// Log action
- logAttivita(
- agentId,
- userLogin,
- `Aggiunta nota a Ticket #${resolved.tn}`,
- { ticket_id: resolved.id, ticket_tn: resolved.tn, subject: note.subject, time_units: note.time_units },
- 'successo'
- );
+ logAttivita({
+ agente_id: agentId,
+ agente_nome: userLogin,
+ titolo_azione: `Aggiunta nota a Ticket #${resolved.tn}`,
+ azione: { ticket_id: resolved.id, ticket_tn: resolved.tn, subject: note.subject, time_units: note.time_units },
+ esito: 'successo'
+ });
// Delete note from local SQLite DB upon successful OTRS upload
db.prepare('DELETE FROM ticket_draft_notes WHERE id = ?').run(note.id);
diff --git a/routes/tickets.js b/routes/tickets.js
index 22019c9..841b82f 100644
--- a/routes/tickets.js
+++ b/routes/tickets.js
@@ -482,7 +482,7 @@ router.post('/', async (req, res) => {
// Generate ticket number: get next counter value for today (daily reset)
const now = new Date();
- const systemId = process.env.OTRS_SYSTEM_ID || '10';
+ const systemId = process.env.OTRS_SYSTEM_ID || String(operatorId);
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
// 1. Fetch current maximum counter in ticket table for today
@@ -748,8 +748,9 @@ router.patch('/:id', async (req, res) => {
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, note }
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
const timeUnit = parseFloat(updates.time_unit);
+ const noteSubjectText = typeof updates.note_subject === 'string' ? updates.note_subject.trim() : '';
const noteText = typeof updates.note === 'string' ? updates.note.trim() : (typeof updates.note_text === 'string' ? updates.note_text.trim() : '');
- const hasNote = noteText.length > 0;
+ const hasNote = noteText.length > 0 || noteSubjectText.length > 0;
// Fetch current ticket first (needed for both REST dummy fields and database fallback)
let current;
@@ -810,8 +811,8 @@ router.patch('/:id', async (req, res) => {
Ticket: ticketFields
};
if (hasNote || (!isNaN(timeUnit) && timeUnit > 0)) {
- const noteSubject = hasNote ? noteText : 'Consuntivazione';
- const noteBody = hasNote ? noteText : 'Consuntivazione';
+ const noteSubject = noteSubjectText || (hasNote ? noteText : 'Consuntivazione');
+ const noteBody = noteText || noteSubjectText || 'Consuntivazione';
const articleObj = {
CommunicationChannel: 'Internal',
SenderType: 'agent',
@@ -993,8 +994,8 @@ router.patch('/:id', async (req, res) => {
const articleId = articleResult.rows[0].id;
const now = new Date();
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
- const noteSubject = hasNote ? noteText : 'Consuntivazione';
- const noteBodyRaw = hasNote ? noteText : 'Consuntivazione';
+ const noteSubject = noteSubjectText || (hasNote ? noteText : 'Consuntivazione');
+ const noteBodyRaw = noteText || noteSubjectText || 'Consuntivazione';
const safeBody = noteBodyRaw.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
');
const htmlBody = `
${safeBody}`;
const binaryBody = Buffer.from(htmlBody, 'utf-8');
@@ -2210,7 +2211,7 @@ router.post('/auto-time', async (req, res) => {
// Generate ticket number (daily reset)
const now = new Date();
- const systemId = process.env.OTRS_SYSTEM_ID || '10';
+ const systemId = process.env.OTRS_SYSTEM_ID || String(operatorId);
const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`;
const maxTicketResult = await client.query(