From f21be0abcb1dd02883bd6fff9e1032fe9dfde722 Mon Sep 17 00:00:00 2001 From: Gabriele Cimaschi Date: Mon, 7 Sep 2026 17:58:13 +0200 Subject: [PATCH] fix: note temporanee che non funzionavano. feat: le note possono essere inviate ad otrs senza numero di ticket. style: rivisti alcuni nomi e colore per invio di una nota temporanea in otrs. --- public/index.html | 2 +- public/js/app.js | 15 +++- public/js/views/noteTemporanee.js | 52 ++++++++------ public/js/views/ricercaSpudorata.js | 2 +- public/js/views/ticketCreate.js | 15 ++-- public/js/views/ticketDetail.js | 29 +++++++- public/js/views/ticketGroups.js | 54 ++++++++++----- routes/notes.js | 102 ++++++++++++++++++++++++---- routes/tickets.js | 15 ++-- 9 files changed, 216 insertions(+), 70 deletions(-) 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 = {

${message}

- +
`; @@ -1136,10 +1136,19 @@ const App = { e.stopPropagation(); onSelect(items[currentIndex]); } - } else if (e.key === 'Escape') { + } else if (e.key === 'Tab' || e.key === 'Escape') { + if (currentIndex >= 0 && items[currentIndex] && e.key === 'Tab') { + onSelect(items[currentIndex]); + } suggestionsEl.style.display = 'none'; } }); + + inputEl.addEventListener('blur', () => { + setTimeout(() => { + if (suggestionsEl) suggestionsEl.style.display = 'none'; + }, 150); + }); }, }; diff --git a/public/js/views/noteTemporanee.js b/public/js/views/noteTemporanee.js index 45077ae..ac863cd 100644 --- a/public/js/views/noteTemporanee.js +++ b/public/js/views/noteTemporanee.js @@ -23,7 +23,7 @@ const NoteTemporaneeView = {
πŸ“
-

Nuova Nota Temporanea

+

Nuova Nota

Le note vengono mantenute in locale finchΓ© non le elimini con il cestino πŸ—‘οΈ o le invii a OTRS πŸš€.

@@ -173,6 +173,14 @@ const NoteTemporaneeView = { } } }); + + // Prevent tab navigation on Quill toolbar items + const toolbar = document.querySelector('#note-body-editor-wrapper .ql-toolbar'); + if (toolbar) { + toolbar.querySelectorAll('button, select, span[role="button"], input').forEach(el => { + el.setAttribute('tabindex', '-1'); + }); + } } }, @@ -364,9 +372,8 @@ const NoteTemporaneeView = {
- - +
@@ -375,10 +382,10 @@ const NoteTemporaneeView = { }).join(''); // Bind item actions - listContainer.querySelectorAll('.btn-edit-note').forEach(btn => { + listContainer.querySelectorAll('.btn-delete-note').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); - this.loadNoteIntoForm(parseInt(btn.dataset.id, 10)); + this.deleteNote(parseInt(btn.dataset.id, 10)); }); }); @@ -389,13 +396,6 @@ const NoteTemporaneeView = { }); }); - listContainer.querySelectorAll('.btn-delete-note').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - this.deleteNote(parseInt(btn.dataset.id, 10)); - }); - }); - listContainer.querySelectorAll('.saved-note-card').forEach(card => { card.addEventListener('click', () => { this.loadNoteIntoForm(parseInt(card.dataset.noteId, 10)); @@ -447,7 +447,7 @@ const NoteTemporaneeView = { const deleteBtn = document.getElementById('note-delete-current-btn'); const ticketClearBtn = document.getElementById('note-ticket-clear-btn'); - if (editorTitle) editorTitle.textContent = 'Nuova Nota Temporanea'; + if (editorTitle) editorTitle.textContent = 'Nuova Nota'; if (ticketInput) ticketInput.value = ''; if (ticketClearBtn) ticketClearBtn.style.display = 'none'; if (subjectInput) subjectInput.value = ''; @@ -487,7 +487,7 @@ const NoteTemporaneeView = { try { const res = await App.api('/api/notes', { method: 'POST', - body: payload + body: JSON.stringify(payload) }); Toast.success('Nota temporanea salvata nel database locale!'); @@ -518,12 +518,19 @@ const NoteTemporaneeView = { const note = this.savedNotes.find(n => n.id === noteId); if (!note) return; - if (!note.ticket_tn && !note.ticket_id) { - Toast.error('Inserisci un ticket di riferimento (TN o ID) prima di inviare a OTRS'); - return; - } + const hasTicket = Boolean(note.ticket_tn || note.ticket_id); + const modalTitle = hasTicket ? 'Invia Nota a OTRS' : 'Crea Nuovo Ticket da Nota'; + const confirmMsg = hasTicket + ? `Sei sicuro di voler inviare questa nota a OTRS per il ticket #${note.ticket_tn || note.ticket_id}? La nota verrΓ  poi rimossa dal database locale.` + : `Non hai specificato un ticket. Vuoi creare un NUOVO ticket a partire da questa nota ed inviarla a OTRS?`; - if (!confirm(`Sei sicuro di voler inviare questa nota a OTRS per il ticket #${note.ticket_tn || note.ticket_id}? La nota verrΓ  poi rimossa dal database locale.`)) { + const ok = await App.confirm(modalTitle, confirmMsg, { + confirmText: hasTicket ? 'Invia a OTRS' : 'Crea Ticket e Invia', + confirmClass: 'btn-success', + cancelText: 'Annulla' + }); + + if (!ok) { return; } @@ -549,7 +556,12 @@ const NoteTemporaneeView = { }, async deleteNote(noteId) { - if (!confirm('Sei sicuro di voler eliminare questa nota temporanea dal database locale?')) { + const ok = await App.confirm( + 'Elimina Nota Temporanea', + 'Sei sicuro di voler eliminare questa nota temporanea dal database locale?', + { confirmText: 'Elimina', cancelText: 'Annulla' } + ); + if (!ok) { return; } diff --git a/public/js/views/ricercaSpudorata.js b/public/js/views/ricercaSpudorata.js index 3fdf668..acc1617 100644 --- a/public/js/views/ricercaSpudorata.js +++ b/public/js/views/ricercaSpudorata.js @@ -59,7 +59,7 @@ const RicercaSpudorataView = {
πŸš€
-

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 = {
+
+ + +
@@ -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.

+
+
+ +

πŸ—‚οΈ Gruppi Ticket

@@ -25,15 +34,6 @@ const TicketGroupsView = {
- - -
-
-
πŸ“‚
-

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(