Compare commits

...
2 Commits
16 changed files with 894 additions and 12 deletions

No files matched your search

+3
View File
@@ -41,6 +41,9 @@ CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc
# Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%)
PHRASE_THRESHOLD=70
# Immagine ironica mostrata in caso di overperformance (default over9000.jpg nella cartella public/img)
OVERPERFORMANCE_IMAGE=over9000.jpg
# Configurazioni per la consuntivazione automatica fine giornata
AUTO_TIME_MIN_HOUR=18:00
AUTO_TIME_QUEUE=Assistenza
+16
View File
@@ -78,6 +78,22 @@ db.exec(`
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS ticket_draft_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL DEFAULT 1,
ticket_id INTEGER,
ticket_tn TEXT,
ticket_title TEXT,
subject TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
time_units REAL DEFAULT 0,
note_type TEXT DEFAULT 'note-internal',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS email_address_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+5 -5
View File
@@ -2403,8 +2403,8 @@ body {
.timer-tooltip {
position: absolute;
left: 105%;
top: 50%;
transform: translateY(-50%) translateX(-8px);
top: -5px;
transform: translateX(-8px);
width: 290px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
@@ -2423,7 +2423,7 @@ body {
.timer-tooltip::after {
content: "";
position: absolute;
top: 50%;
top: 18px;
right: 100%;
transform: translateY(-50%);
border-width: 6px;
@@ -2433,7 +2433,7 @@ body {
.timer-tooltip-border {
position: absolute;
top: 50%;
top: 18px;
right: 100%;
transform: translateY(-50%);
border-width: 7px;
@@ -2444,7 +2444,7 @@ body {
.sidebar-timer:hover .timer-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
transform: translateX(0);
pointer-events: auto;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+13
View File
@@ -80,6 +80,18 @@
<span>Ricerca Spudorata</span>
</a>
</li>
<li>
<a href="#/note-temporanee" class="nav-link" data-view="note-temporanee" id="nav-note-temporanee">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
<span>Note Temporanee</span>
<span class="nav-badge" id="temp-notes-count"></span>
</a>
</li>
<li>
<a href="#/tickets/groups" class="nav-link" data-view="ticket-groups" id="nav-ticket-groups">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -242,6 +254,7 @@
<script src="/js/views/timeAccounting.js"></script>
<script src="/js/views/ticketGroups.js"></script>
<script src="/js/views/envConfig.js"></script>
<script src="/js/views/noteTemporanee.js"></script>
<script src="/js/app.js"></script>
</body>
+61 -6
View File
@@ -307,9 +307,15 @@ const App = {
TicketListView.render();
} else if (hash === '#/ricerca-spudorata') {
document.getElementById('nav-ricerca-spudorata')?.classList.add('active');
titleEl.textContent = 'Ricerca Spudorata';
RicercaSpudorataView.render();
} else if (hash === '#/note-temporanee') {
document.getElementById('nav-note-temporanee')?.classList.add('active');
titleEl.textContent = 'Note Temporanee';
NoteTemporaneeView.render();
} else if (hash === '#/tickets/new') {
document.getElementById('nav-new-ticket')?.classList.add('active');
titleEl.textContent = 'Nuovo Ticket';
@@ -756,6 +762,32 @@ const App = {
}
}
const overDelta = Math.max(0, mathematicalPercentage - 100);
const visualBarWidth = hasOverperformance ? (100 + (overDelta * 3.5)) : percentage;
let overImgName = 'over9000.jpg';
let overPhraseText = '';
if (mathematicalPercentage > 150) {
overImgName = this.lookups?.config?.overperformanceImage || 'over9000.jpg';
overPhraseText = "IT'S OVER 9000! Il livello di consuntivazione ha superato ogni limite!";
} else if (mathematicalPercentage > 140) {
overImgName = 'over_150.jpg';
overPhraseText = 'Questo livello di consuntivazione è impossibile.';
} else if (mathematicalPercentage > 130) {
overImgName = 'over_140.jpg';
overPhraseText = 'Io sono il principe dei Saiyan, non glielo permetterò';
} else if (mathematicalPercentage > 120) {
overImgName = 'over_130.jpg';
overPhraseText = 'Sono un Saiyan, non può consuntivare più di me.';
} else if (mathematicalPercentage > 110) {
overImgName = 'over_120.jpg';
overPhraseText = 'Non è possibile! La sua consuntivazione continua ad aumentare.';
} else {
overImgName = 'over_110.jpg';
overPhraseText = "C'è qualcosa di sicuro nella vita. Il più forte è al di sopra di tutti e può consuntivare tutto!";
}
timerEl.innerHTML = `
<div class="timer-display" style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; font-weight:500;">
<span>⏱ ${todayTime} / ${targetTime} m</span>
@@ -764,21 +796,36 @@ const App = {
</div>
<div class="timer-tooltip">
<div class="timer-tooltip-border"></div>
<div style="font-size:0.75rem; font-weight:600; color:var(--text-primary); margin-bottom:4px; display:flex; justify-content:space-between;">
<div style="font-size:0.75rem; font-weight:600; color:var(--text-primary); margin-bottom:4px; display:flex; justify-content:space-between; align-items:center;">
<span>Progresso Giornaliero</span>
<strong>${mathematicalPercentage}%</strong>
<strong>${mathematicalPercentage}% ${hasOverperformance ? '<span style="animation: pulse 1.5s infinite; display:inline-block; margin-left:4px;">⚠️</span>' : ''}</strong>
</div>
<div style="background:var(--border-light); border-radius:var(--radius-full); height:10px; width:100%; overflow:hidden; border:1px solid var(--border-subtle);">
<div style="width:${percentage}%; background:linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); height:100%; border-radius:inherit; transition: width 0.3s ease;"></div>
<div style="background:var(--border-light); border-radius:var(--radius-full); height:10px; width:100%; position:relative; overflow:visible; border:1px solid var(--border-subtle);">
<div style="width:${visualBarWidth}%; background:${hasOverperformance ? 'linear-gradient(90deg, var(--accent-primary), #ef4444, #f59e0b, #ec4899)' : 'linear-gradient(90deg, var(--accent-primary), var(--accent-secondary))'}; height:100%; border-radius:var(--radius-full); transition: width 0.3s ease; ${hasOverperformance ? 'box-shadow: 0 0 10px rgba(239, 68, 68, 0.8);' : ''}"></div>
</div>
${hasOverperformance ? `
${hasOverperformance ? (() => {
const overImgSrc = (overImgName.startsWith('http://') || overImgName.startsWith('https://') || overImgName.startsWith('/')) ? overImgName : `img/${overImgName}`;
const imgWidth = Math.min(500, 180 + Math.round(overDelta * 10));
return `
<div style="font-size:0.72rem; color:var(--error); font-weight:700; line-height:1.3; text-transform:uppercase; margin-top:8px; border-top:1px solid var(--border-subtle); padding-top:8px; text-align:center; animation: pulse 1.5s infinite;">
⚠️ Rilevata una overperformance allontanarsi dalla postazione immediatamente
Rilevata una overperformance allontanarsi <span style="font-size:1.4rem; display:block; font-weight:900; margin-top:2px;">IMMEDIATAMENTE</span>
</div>
<div style="margin-top:6px; text-align:center; position:relative; overflow:visible;">
<a href="${App.escapeHtml(overImgSrc)}" target="_blank" title="Apri immagine originale in una nuova scheda" style="display:inline-block; text-decoration:none;">
<img src="${App.escapeHtml(overImgSrc)}" alt="Overperformance Meme" style="width:${imgWidth}px; max-width:none; border-radius:var(--radius-md); box-shadow:var(--shadow-md); display:block; margin:0 auto; transition: width 0.3s ease; cursor:pointer;" />
</a>
</div>
${overPhraseText ? `
<div style="font-size:0.75rem; color:var(--text-primary); font-weight:600; line-height:1.35; font-style:italic; margin-top:8px; border-top:1px dashed var(--border-subtle); padding-top:6px; text-align:center;">
"${App.escapeHtml(overPhraseText)}"
</div>
` : ''}
`;
})() : `
<div style="font-size:0.72rem; color:var(--text-secondary); line-height:1.35; font-style:italic; margin-top:6px; border-top:1px solid var(--border-subtle); padding-top:6px; text-align:center;">
"${phrase}"
</div>
`}
</div>
`;
// Update sidebar counts as well
@@ -802,6 +849,14 @@ const App = {
if (myBadge) {
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
}
try {
const notes = await this.api('/api/notes');
const notesBadge = document.getElementById('temp-notes-count');
if (notesBadge) {
notesBadge.textContent = (notes && notes.length > 0) ? notes.length : '';
}
} catch (_) {}
} catch (e) {
console.warn('Failed to update sidebar badges:', e);
}
+571
View File
@@ -0,0 +1,571 @@
/**
* Note Temporanee View (public/js/views/noteTemporanee.js)
* Temporary draft notes manager saved locally in SQLite until manually deleted or uploaded to OTRS.
* Uses Quill Rich Text Editor for advanced formatting matching Ticket Detail notes.
*/
const NoteTemporaneeView = {
currentNoteId: null,
savedNotes: [],
selectedTicket: null,
quill: null,
async render() {
const container = document.getElementById('view-container');
if (!container) return;
container.innerHTML = `
<div class="note-temporanee-page" style="display: grid; grid-template-columns: 1fr 340px; gap: var(--space-lg); align-items: start;">
<!-- Left / Main Column: Note Editor -->
<div class="main-editor-area">
<div class="card" style="margin-bottom: 0;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md); flex-wrap:wrap; gap:10px;">
<div style="display:flex; align-items:center; gap: 10px;">
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2), rgba(16, 185, 129, 0.2)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">📝</div>
<div>
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);" id="editor-title-text">Nuova Nota Temporanea</h2>
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
Le note vengono mantenute in locale finché non le elimini con il cestino 🗑️ o le invii a OTRS 🚀.
</p>
</div>
</div>
<button class="btn btn-ghost btn-sm" id="note-clear-form-btn" style="display:flex; align-items:center; gap:4px; font-size:0.82rem;">
✨ Nuova Nota
</button>
</div>
<!-- Form -->
<form id="note-form" onsubmit="return false;" style="display:flex; flex-direction:column; gap: var(--space-md);">
<!-- Ticket Link / Selector -->
<div class="form-group" style="position:relative;">
<label class="form-label" style="font-weight:600; font-size:0.85rem; display:flex; justify-content:space-between;">
<span>Ticket di riferimento (opzionale):</span>
<span id="ticket-resolved-badge" style="font-weight:normal; color:var(--accent-primary); font-size:0.8rem; display:none;"></span>
</label>
<div style="position:relative; display:flex; align-items:center;">
<input type="text" id="note-ticket-input" class="form-input" placeholder="Cerca o inserisci numero ticket (TN) o ID (es. 2024082610000123)..." autocomplete="off" style="padding-right:30px;" />
<button type="button" id="note-ticket-clear-btn" style="position:absolute; right:8px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-muted); cursor:pointer; display:none;" title="Rimuovi ticket">✕</button>
</div>
<div id="note-ticket-suggestions" class="autocomplete-suggestions" style="display:none; position:absolute; top:100%; left:0; right:0; z-index:1000;"></div>
</div>
<!-- Subject and Type Row -->
<div style="display:grid; grid-template-columns: 2fr 1fr; gap: var(--space-md);">
<div class="form-group">
<label class="form-label" style="font-weight:600; font-size:0.85rem;">Oggetto / Titolo Nota:</label>
<input type="text" id="note-subject" class="form-input" placeholder="Es. Appunti chiamata cliente / Verifica server..." />
</div>
<div class="form-group">
<label class="form-label" style="font-weight:600; font-size:0.85rem;">Tipo Nota:</label>
<select id="note-type" class="form-select">
<option value="note-internal">🔒 Nota Interna</option>
<option value="note-external">🌐 Nota Esterna</option>
<option value="email-external">✉️ Email Cliente</option>
</select>
</div>
</div>
<!-- Advanced Quill Rich Text Body Editor -->
<div class="form-group">
<label class="form-label" style="font-weight:600; font-size:0.85rem;">Corpo della Nota:</label>
<div id="note-body-editor-wrapper" style="background:var(--bg-card); border-radius:var(--radius-md); border:1px solid var(--border-light); overflow:hidden;">
<div id="note-body-editor" style="min-height:220px; font-size:0.9rem;"></div>
</div>
</div>
<!-- Time Accounting Units -->
<div style="display:flex; align-items:center; justify-content:space-between; background:var(--bg-secondary); padding:var(--space-sm) var(--space-md); border-radius:var(--radius-md); border:1px solid var(--border-subtle); flex-wrap:wrap; gap:10px;">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:1.1rem;">⏱</span>
<label for="note-time-units" style="font-size:0.85rem; font-weight:600; color:var(--text-primary); margin:0;">Tempo di consuntivazione (minuti):</label>
</div>
<input type="number" id="note-time-units" class="form-input" min="0" step="5" value="0" style="width:100px; text-align:right; font-weight:600; height:34px;" />
</div>
<!-- Action Buttons -->
<div style="display:flex; align-items:center; justify-content:flex-end; gap:var(--space-sm); margin-top:var(--space-xs); border-top:1px solid var(--border-subtle); padding-top:var(--space-md);">
<button type="button" class="btn btn-ghost" id="note-delete-current-btn" style="display:none; color:var(--error); margin-right:auto;">
🗑️ Elimina Bozza
</button>
<button type="button" class="btn btn-secondary" id="note-save-local-btn" style="min-width:140px; display:flex; align-items:center; justify-content:center; gap:6px;">
💾 Salva in Locale
</button>
<button type="button" class="btn btn-primary" id="note-submit-otrs-btn" style="min-width:150px; display:flex; align-items:center; justify-content:center; gap:6px; background:var(--accent-primary);">
🚀 Invia a OTRS
</button>
</div>
</form>
</div>
</div>
<!-- Right Side Column: Summary Menu of Saved Notes -->
<div class="saved-notes-sidebar">
<div class="card" style="margin-bottom:0; padding:var(--space-md);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<h3 style="margin:0; font-size:0.95rem; font-weight:700; color:var(--text-primary); display:flex; align-items:center; gap:6px;">
<span>📋</span> Note Salvate
<span class="badge" id="notes-sidebar-count" style="background:var(--accent-primary); color:#fff; font-size:0.75rem; padding:2px 8px; border-radius:10px;">0</span>
</h3>
<button class="btn btn-ghost btn-xs" id="notes-refresh-btn" title="Aggiorna lista note">🔄</button>
</div>
<!-- Search filter in saved notes -->
<div style="margin-bottom:var(--space-md); position:relative;">
<input type="text" id="notes-sidebar-search" class="form-input" placeholder="Filtra note..." style="width:100%; height:32px; font-size:0.8rem; padding-left:28px;" />
<span style="position:absolute; left:8px; top:50%; transform:translateY(-50%); font-size:0.8rem; color:var(--text-muted);">🔍</span>
</div>
<!-- Saved Notes List Container -->
<div id="saved-notes-list-container" style="max-height: calc(100vh - 270px); overflow-y:auto; display:flex; flex-direction:column; gap:var(--space-sm); padding-right:2px;">
<div style="text-align:center; padding:20px; color:var(--text-muted); font-size:0.85rem;">
Caricamento note...
</div>
</div>
</div>
</div>
</div>
`;
this.initQuill();
this.bindEvents();
await this.loadSavedNotes();
// Check if URL hash has parameters like ?ticket_id=... or ?ticket_tn=...
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
const ticketTnParam = urlParams.get('ticket_tn') || urlParams.get('tn');
if (ticketTnParam) {
const input = document.getElementById('note-ticket-input');
if (input) {
input.value = ticketTnParam;
this.resolveTicketInput(ticketTnParam);
}
}
},
initQuill() {
const editorEl = document.getElementById('note-body-editor');
if (!editorEl) return;
if (window.Quill) {
this.quill = new Quill('#note-body-editor', {
theme: 'snow',
placeholder: 'Scrivi il contenuto dettagliato della nota temporanea...',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'color': [] }, { 'background': [] }],
['link', 'image'],
['clean']
],
keyboard: {
bindings: {
tab: {
key: 'Tab',
handler: function () {
return true;
}
}
}
}
}
});
}
},
getNoteBodyHtml() {
if (this.quill) {
const html = this.quill.root.innerHTML;
if (html === '<p><br></p>') return '';
return html;
}
return '';
},
setNoteBodyHtml(html) {
if (this.quill) {
this.quill.root.innerHTML = html || '';
}
},
bindEvents() {
const saveBtn = document.getElementById('note-save-local-btn');
const submitBtn = document.getElementById('note-submit-otrs-btn');
const clearBtn = document.getElementById('note-clear-form-btn');
const deleteBtn = document.getElementById('note-delete-current-btn');
const refreshBtn = document.getElementById('notes-refresh-btn');
const searchInput = document.getElementById('notes-sidebar-search');
const ticketInput = document.getElementById('note-ticket-input');
const ticketClearBtn = document.getElementById('note-ticket-clear-btn');
if (saveBtn) saveBtn.addEventListener('click', () => this.saveLocalNote());
if (submitBtn) submitBtn.addEventListener('click', () => this.submitToOTRS());
if (clearBtn) clearBtn.addEventListener('click', () => this.clearForm());
if (deleteBtn) deleteBtn.addEventListener('click', () => {
if (this.currentNoteId) this.deleteNote(this.currentNoteId);
});
if (refreshBtn) refreshBtn.addEventListener('click', () => this.loadSavedNotes());
if (searchInput) {
searchInput.addEventListener('input', () => this.renderSavedNotesList());
}
if (ticketInput) {
let debounceTimer = null;
ticketInput.addEventListener('input', () => {
const val = ticketInput.value.trim();
if (ticketClearBtn) ticketClearBtn.style.display = val ? 'block' : 'none';
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
this.resolveTicketInput(val);
}, 400);
});
}
if (ticketClearBtn) {
ticketClearBtn.addEventListener('click', () => {
if (ticketInput) ticketInput.value = '';
ticketClearBtn.style.display = 'none';
this.selectedTicket = null;
this.updateTicketBadge(null);
});
}
},
async resolveTicketInput(val) {
if (!val) {
this.selectedTicket = null;
this.updateTicketBadge(null);
return;
}
try {
const res = await App.api(`/api/tickets?search=${encodeURIComponent(val)}&per_page=5`);
const tickets = res.tickets || [];
if (tickets.length > 0) {
const exact = tickets.find(t => String(t.tn) === String(val) || String(t.id) === String(val)) || tickets[0];
this.selectedTicket = exact;
this.updateTicketBadge(exact);
} else {
this.selectedTicket = null;
this.updateTicketBadge(null, `Nessun ticket trovato per "${val}"`);
}
} catch (_) {
this.selectedTicket = null;
this.updateTicketBadge(null);
}
},
updateTicketBadge(ticket, errorMsg = '') {
const badge = document.getElementById('ticket-resolved-badge');
if (!badge) return;
if (ticket) {
badge.style.display = 'inline-block';
badge.style.color = 'var(--accent-primary)';
badge.innerHTML = `✓ Ticket #${ticket.tn} — ${App.escapeHtml(ticket.title ? (ticket.title.length > 40 ? ticket.title.substring(0, 37) + '...' : ticket.title) : '')}`;
} else if (errorMsg) {
badge.style.display = 'inline-block';
badge.style.color = 'var(--error)';
badge.innerHTML = App.escapeHtml(errorMsg);
} else {
badge.style.display = 'none';
badge.innerHTML = '';
}
},
async loadSavedNotes() {
try {
const listContainer = document.getElementById('saved-notes-list-container');
if (listContainer) {
listContainer.innerHTML = '<div style="text-align:center; padding:15px;"><div class="spinner" style="width:20px;height:20px;margin:0 auto;"></div></div>';
}
this.savedNotes = await App.api('/api/notes');
this.renderSavedNotesList();
const countBadge = document.getElementById('notes-sidebar-count');
if (countBadge) countBadge.textContent = this.savedNotes.length;
// Update sidebar nav badge
const navBadge = document.getElementById('temp-notes-count');
if (navBadge) navBadge.textContent = this.savedNotes.length || '';
} catch (err) {
console.error('Failed to load saved notes:', err);
Toast.error('Errore nel caricamento delle note salvate');
}
},
renderSavedNotesList() {
const listContainer = document.getElementById('saved-notes-list-container');
if (!listContainer) return;
const searchInput = document.getElementById('notes-sidebar-search');
const searchVal = searchInput ? searchInput.value.trim().toLowerCase() : '';
let list = this.savedNotes || [];
if (searchVal) {
list = list.filter(n =>
(n.ticket_tn && n.ticket_tn.toLowerCase().includes(searchVal)) ||
(n.subject && n.subject.toLowerCase().includes(searchVal)) ||
(n.body && n.body.toLowerCase().includes(searchVal)) ||
(n.ticket_title && n.ticket_title.toLowerCase().includes(searchVal))
);
}
if (list.length === 0) {
listContainer.innerHTML = `
<div style="text-align:center; padding:25px 10px; color:var(--text-tertiary); font-size:0.85rem; border:1px dashed var(--border-subtle); border-radius:var(--radius-md);">
${searchVal ? 'Nessuna nota corrisponde alla ricerca' : 'Nessuna nota temporanea salvata'}
</div>
`;
return;
}
listContainer.innerHTML = list.map(n => {
const isSelected = String(n.id) === String(this.currentNoteId);
const isInternal = n.note_type === 'note-internal';
const isEmail = n.note_type === 'email-external';
const typeLabel = isInternal ? '🔒 Interna' : (isEmail ? '✉️ Email' : '🌐 Esterna');
const timeStr = n.time_units > 0 ? `⏱ ${n.time_units}m` : '';
// Strip HTML tags for clean text preview
const tmpDiv = document.createElement('div');
tmpDiv.innerHTML = n.body || '';
const textPreview = tmpDiv.textContent || tmpDiv.innerText || '';
return `
<div class="saved-note-card ${isSelected ? 'selected' : ''}" data-note-id="${n.id}" style="background: ${isSelected ? 'rgba(79,70,229,0.08)' : 'var(--bg-card)'}; border: 1px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-subtle)'}; border-radius: var(--radius-md); padding: 10px; transition: all 0.2s ease;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; font-size:0.75rem;">
${n.ticket_tn ? `
<span class="badge" style="background:var(--accent-primary); color:#fff; font-weight:700; font-size:0.72rem; padding:1px 6px;">#${n.ticket_tn}</span>
` : `
<span class="badge" style="background:var(--bg-secondary); color:var(--text-tertiary); font-size:0.72rem; padding:1px 6px;">Senza Ticket</span>
`}
<span style="color:var(--text-tertiary);">${App.formatDateTime(n.updated_at)}</span>
</div>
<div style="font-size:0.85rem; font-weight:700; color:var(--text-primary); margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
${App.escapeHtml(n.subject || '(Senza titolo)')}
</div>
<div style="font-size:0.8rem; color:var(--text-secondary); line-height:1.3; margin-bottom:8px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
${App.escapeHtml(textPreview)}
</div>
<div style="display:flex; align-items:center; justify-content:space-between; font-size:0.75rem; border-top:1px solid var(--border-subtle); padding-top:6px; margin-top:4px;">
<div style="display:flex; align-items:center; gap:6px;">
<span style="color:var(--text-tertiary); font-size:0.72rem;">${typeLabel}</span>
${timeStr ? `<span style="font-weight:600; color:var(--accent-primary); font-size:0.72rem;">${timeStr}</span>` : ''}
</div>
<div style="display:flex; align-items:center; gap:4px;">
<button class="btn btn-ghost btn-xs btn-edit-note" data-id="${n.id}" title="Carica ed modifica nota" style="padding:2px 5px; font-size:0.75rem;">✏️</button>
<button class="btn btn-ghost btn-xs btn-submit-note" data-id="${n.id}" title="Invia direttamente a OTRS" style="padding:2px 5px; font-size:0.75rem; color:var(--accent-primary);">🚀</button>
<button class="btn btn-ghost btn-xs btn-delete-note" data-id="${n.id}" title="Elimina definitivamente dal DB locale" style="padding:2px 5px; font-size:0.75rem; color:var(--error);">🗑️</button>
</div>
</div>
</div>
`;
}).join('');
// Bind item actions
listContainer.querySelectorAll('.btn-edit-note').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.loadNoteIntoForm(parseInt(btn.dataset.id, 10));
});
});
listContainer.querySelectorAll('.btn-submit-note').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.submitNoteById(parseInt(btn.dataset.id, 10));
});
});
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));
});
});
},
loadNoteIntoForm(noteId) {
const note = this.savedNotes.find(n => n.id === noteId);
if (!note) return;
this.currentNoteId = note.id;
const editorTitle = document.getElementById('editor-title-text');
const ticketInput = document.getElementById('note-ticket-input');
const subjectInput = document.getElementById('note-subject');
const typeSelect = document.getElementById('note-type');
const timeInput = document.getElementById('note-time-units');
const deleteBtn = document.getElementById('note-delete-current-btn');
const ticketClearBtn = document.getElementById('note-ticket-clear-btn');
if (editorTitle) editorTitle.textContent = `Modifica Nota #${note.id}`;
if (ticketInput) ticketInput.value = note.ticket_tn || (note.ticket_id ? String(note.ticket_id) : '');
if (ticketClearBtn) ticketClearBtn.style.display = ticketInput && ticketInput.value ? 'block' : 'none';
if (subjectInput) subjectInput.value = note.subject || '';
if (typeSelect) typeSelect.value = note.note_type || 'note-internal';
this.setNoteBodyHtml(note.body || '');
if (timeInput) timeInput.value = note.time_units || 0;
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
if (note.ticket_tn || note.ticket_id) {
this.resolveTicketInput(note.ticket_tn || String(note.ticket_id));
} else {
this.selectedTicket = null;
this.updateTicketBadge(null);
}
this.renderSavedNotesList();
},
clearForm() {
this.currentNoteId = null;
const editorTitle = document.getElementById('editor-title-text');
const ticketInput = document.getElementById('note-ticket-input');
const subjectInput = document.getElementById('note-subject');
const typeSelect = document.getElementById('note-type');
const timeInput = document.getElementById('note-time-units');
const deleteBtn = document.getElementById('note-delete-current-btn');
const ticketClearBtn = document.getElementById('note-ticket-clear-btn');
if (editorTitle) editorTitle.textContent = 'Nuova Nota Temporanea';
if (ticketInput) ticketInput.value = '';
if (ticketClearBtn) ticketClearBtn.style.display = 'none';
if (subjectInput) subjectInput.value = '';
if (typeSelect) typeSelect.value = 'note-internal';
this.setNoteBodyHtml('');
if (timeInput) timeInput.value = '0';
if (deleteBtn) deleteBtn.style.display = 'none';
this.selectedTicket = null;
this.updateTicketBadge(null);
this.renderSavedNotesList();
},
async saveLocalNote() {
const ticketInput = document.getElementById('note-ticket-input');
const subjectInput = document.getElementById('note-subject');
const typeSelect = document.getElementById('note-type');
const timeInput = document.getElementById('note-time-units');
const bodyVal = this.getNoteBodyHtml();
if (!bodyVal) {
Toast.error('Inserisci il corpo della nota prima di salvare');
return;
}
const payload = {
id: this.currentNoteId,
ticket_id: this.selectedTicket ? this.selectedTicket.id : null,
ticket_tn: ticketInput ? ticketInput.value.trim() : null,
ticket_title: this.selectedTicket ? this.selectedTicket.title : null,
subject: subjectInput ? subjectInput.value.trim() : '',
body: bodyVal,
time_units: timeInput ? parseFloat(timeInput.value) || 0 : 0,
note_type: typeSelect ? typeSelect.value : 'note-internal'
};
try {
const res = await App.api('/api/notes', {
method: 'POST',
body: payload
});
Toast.success('Nota temporanea salvata nel database locale!');
this.currentNoteId = res.id;
await this.loadSavedNotes();
} catch (err) {
console.error(err);
Toast.error('Errore nel salvataggio della nota: ' + err.message);
}
},
async submitToOTRS() {
const bodyVal = this.getNoteBodyHtml();
if (!bodyVal) {
Toast.error('Inserisci il corpo della nota prima di inviare a OTRS');
return;
}
// First ensure the note is saved locally
await this.saveLocalNote();
if (!this.currentNoteId) return;
await this.submitNoteById(this.currentNoteId);
},
async submitNoteById(noteId) {
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;
}
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.`)) {
return;
}
try {
const res = await App.api(`/api/notes/${noteId}/submit`, {
method: 'POST'
});
Toast.success(res.message || 'Nota inviata con successo a OTRS!');
if (String(this.currentNoteId) === String(noteId)) {
this.clearForm();
}
await this.loadSavedNotes();
// Trigger daily timer refresh if time units were added
if (note.time_units > 0 && App.updateDailyTimer) {
App.updateDailyTimer();
}
} catch (err) {
console.error(err);
Toast.error('Errore invio OTRS: ' + err.message);
}
},
async deleteNote(noteId) {
if (!confirm('Sei sicuro di voler eliminare questa nota temporanea dal database locale?')) {
return;
}
try {
await App.api(`/api/notes/${noteId}`, {
method: 'DELETE'
});
Toast.success('Nota temporanea eliminata dal DB locale');
if (String(this.currentNoteId) === String(noteId)) {
this.clearForm();
}
await this.loadSavedNotes();
} catch (err) {
console.error(err);
Toast.error('Errore eliminazione nota: ' + err.message);
}
}
};
+2 -1
View File
@@ -151,7 +151,8 @@ router.get('/config', async (req, res) => {
phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70,
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00',
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
agentEmail: agentEmail
agentEmail: agentEmail,
overperformanceImage: process.env.OVERPERFORMANCE_IMAGE || 'over9000.jpg'
});
});
+221
View File
@@ -0,0 +1,221 @@
const express = require('express');
const router = express.Router();
const { db, logAttivita } = require('../activityDb');
const pool = require('../db');
// Helper to resolve ticket info from PostgreSQL given ID or TN
async function resolveTicketInfo(ticketId, ticketTn) {
try {
if (ticketId) {
const res = await pool.query('SELECT id, tn, title FROM ticket WHERE id = $1', [ticketId]);
if (res.rows.length > 0) return res.rows[0];
} else if (ticketTn) {
const res = await pool.query('SELECT id, tn, title FROM ticket WHERE tn = $1', [ticketTn.trim()]);
if (res.rows.length > 0) return res.rows[0];
}
} catch (err) {
console.warn('[Notes API] Error resolving ticket info:', err.message);
}
return null;
}
// GET /api/notes - List all draft notes for current agent
router.get('/', (req, res) => {
try {
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
const notes = db.prepare(`
SELECT * FROM ticket_draft_notes
WHERE agent_id = ?
ORDER BY updated_at DESC
`).all(agentId);
res.json(notes);
} catch (err) {
console.error('[Notes API] GET / failed:', err);
res.status(500).json({ error: 'Errore nel caricamento delle note temporanee', message: err.message });
}
});
// GET /api/notes/:id - Get single draft note
router.get('/:id', (req, res) => {
try {
const note = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ?').get(req.params.id);
if (!note) {
return res.status(404).json({ error: 'Nota temporanea non trovata' });
}
res.json(note);
} catch (err) {
res.status(500).json({ error: 'Errore nel recupero della nota', message: err.message });
}
});
// POST /api/notes - Create or update a temporary draft note
router.post('/', async (req, res) => {
try {
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
let { id, ticket_id, ticket_tn, ticket_title, subject, body, time_units, note_type } = req.body;
if (!body || body.trim() === '') {
return res.status(400).json({ error: 'Il corpo della nota non può essere vuoto' });
}
// Resolve ticket info if missing TN or ID
const resolved = await resolveTicketInfo(ticket_id, ticket_tn);
if (resolved) {
ticket_id = resolved.id;
ticket_tn = resolved.tn;
ticket_title = resolved.title;
}
const now = new Date().toISOString();
if (id) {
// Update existing note
db.prepare(`
UPDATE ticket_draft_notes
SET ticket_id = ?, ticket_tn = ?, ticket_title = ?, subject = ?, body = ?, time_units = ?, note_type = ?, updated_at = ?
WHERE id = ? AND agent_id = ?
`).run(ticket_id || null, ticket_tn || null, ticket_title || null, subject || '', body, parseFloat(time_units) || 0, note_type || 'note-internal', now, id, agentId);
const updated = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ?').get(id);
return res.json(updated);
} else {
// Insert new note
const info = db.prepare(`
INSERT INTO ticket_draft_notes (agent_id, ticket_id, ticket_tn, ticket_title, subject, body, time_units, note_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(agentId, ticket_id || null, ticket_tn || null, ticket_title || null, subject || '', body, parseFloat(time_units) || 0, note_type || 'note-internal', now, now);
const inserted = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ?').get(info.lastInsertRowid);
return res.status(201).json(inserted);
}
} catch (err) {
console.error('[Notes API] POST / failed:', err);
res.status(500).json({ error: 'Errore nel salvataggio della nota temporanea', message: err.message });
}
});
// DELETE /api/notes/:id - Delete a draft note permanently
router.delete('/:id', (req, res) => {
try {
const info = db.prepare('DELETE FROM ticket_draft_notes WHERE id = ?').run(req.params.id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Nota temporanea non trovata' });
}
res.json({ success: true, id: req.params.id });
} catch (err) {
res.status(500).json({ error: 'Errore nell\'eliminazione della nota', message: err.message });
}
});
// DELETE /api/notes/by-ticket/:identifier - Delete notes tied to ticket ID or TN
router.delete('/by-ticket/:identifier', async (req, res) => {
try {
const identifier = req.params.identifier;
const resolved = await resolveTicketInfo(parseInt(identifier, 10) || null, identifier);
if (resolved) {
db.prepare('DELETE FROM ticket_draft_notes WHERE ticket_id = ? OR ticket_tn = ?').run(resolved.id, resolved.tn);
} else {
db.prepare('DELETE FROM ticket_draft_notes WHERE ticket_id = ? OR ticket_tn = ?').run(identifier, identifier);
}
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Errore nel ripristino/pulizia della nota', message: err.message });
}
});
// POST /api/notes/:id/submit - Submit note directly to OTRS and remove from SQLite upon success
router.post('/:id/submit', async (req, res) => {
try {
const agentId = parseInt(req.headers['x-agent-id'] || '1', 10);
const note = db.prepare('SELECT * FROM ticket_draft_notes WHERE id = ? AND agent_id = ?').get(req.params.id, agentId);
if (!note) {
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 || ''})` });
}
// Get agent login user
let userLogin = process.env.OTRS_API_USER || 'root@localhost';
try {
const prefRes = await pool.query(
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
[agentId]
);
if (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) {
userLogin = prefRes.rows[0].preferences_value;
}
} catch (_) {}
// Prepare Article for OTRS REST API
const isInternal = note.note_type === 'note-internal';
const isEmail = note.note_type === 'email-external';
let articleType = isInternal ? 'note-internal' : (isEmail ? 'email-external' : 'note-external');
let senderType = isInternal ? 'agent' : (isEmail ? 'agent' : 'agent');
const articlePayload = {
ArticleType: articleType,
SenderType: senderType,
Subject: note.subject || `Nota per ticket ${resolved.tn}`,
Body: note.body,
ContentType: 'text/html; charset=UTF-8',
TimeUnit: parseFloat(note.time_units) || 0
};
const otrsUrl = process.env.OTRS_API_URL || 'http://localhost/otrs/nph-genericinterface.pl/Webservice/GenericTicketConnectorREST';
const otrsPassword = process.env.OTRS_API_PASSWORD || '';
const payload = {
UserLogin: userLogin,
Password: otrsPassword,
TicketID: resolved.id,
Article: articlePayload
};
const otrsRes = await fetch(`${otrsUrl}/TicketUpdate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const otrsData = await otrsRes.json();
if (otrsData && otrsData.Error) {
throw new Error(otrsData.Error.ErrorMessage || 'Errore durante l\'invio a OTRS');
}
// 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'
);
// Delete note from local SQLite DB upon successful OTRS upload
db.prepare('DELETE FROM ticket_draft_notes WHERE id = ?').run(note.id);
res.json({
success: true,
message: `Nota inviata con successo a OTRS per il ticket #${resolved.tn} e rimossa dal database locale.`,
ticket_id: resolved.id,
ticket_tn: resolved.tn
});
} catch (err) {
console.error('[Notes API] POST /:id/submit failed:', err);
res.status(500).json({ error: 'Errore durante l\'invio della nota a OTRS', message: err.message });
}
});
module.exports = router;
+2
View File
@@ -13,6 +13,7 @@ const emailRouter = require('./routes/email');
const groupsRouter = require('./routes/groups');
const presetsRouter = require('./routes/presets');
const configRouter = require('./routes/config');
const notesRouter = require('./routes/notes');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -33,6 +34,7 @@ app.use('/api/email', emailRouter);
app.use('/api/groups', groupsRouter);
app.use('/api/presets', presetsRouter);
app.use('/api/config', configRouter);
app.use('/api/notes', notesRouter);
// SPA fallback — serve index.html for all non-API routes
app.get('*', (req, res) => {