Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3885130a | ||
|
|
146d4ed661 |
No files matched your search
@@ -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%)
|
# Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%)
|
||||||
PHRASE_THRESHOLD=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
|
# Configurazioni per la consuntivazione automatica fine giornata
|
||||||
AUTO_TIME_MIN_HOUR=18:00
|
AUTO_TIME_MIN_HOUR=18:00
|
||||||
AUTO_TIME_QUEUE=Assistenza
|
AUTO_TIME_QUEUE=Assistenza
|
||||||
|
|||||||
@@ -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(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS email_address_groups (
|
CREATE TABLE IF NOT EXISTS email_address_groups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 31 KiB |
@@ -2403,8 +2403,8 @@ body {
|
|||||||
.timer-tooltip {
|
.timer-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 105%;
|
left: 105%;
|
||||||
top: 50%;
|
top: -5px;
|
||||||
transform: translateY(-50%) translateX(-8px);
|
transform: translateX(-8px);
|
||||||
width: 290px;
|
width: 290px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
@@ -2423,7 +2423,7 @@ body {
|
|||||||
.timer-tooltip::after {
|
.timer-tooltip::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 18px;
|
||||||
right: 100%;
|
right: 100%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
border-width: 6px;
|
border-width: 6px;
|
||||||
@@ -2433,7 +2433,7 @@ body {
|
|||||||
|
|
||||||
.timer-tooltip-border {
|
.timer-tooltip-border {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 18px;
|
||||||
right: 100%;
|
right: 100%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
border-width: 7px;
|
border-width: 7px;
|
||||||
@@ -2444,7 +2444,7 @@ body {
|
|||||||
|
|
||||||
.sidebar-timer:hover .timer-tooltip {
|
.sidebar-timer:hover .timer-tooltip {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(-50%) translateX(0);
|
transform: translateX(0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 970 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -80,6 +80,18 @@
|
|||||||
<span>Ricerca Spudorata</span>
|
<span>Ricerca Spudorata</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<a href="#/tickets/groups" class="nav-link" data-view="ticket-groups" id="nav-ticket-groups">
|
<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">
|
<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/timeAccounting.js"></script>
|
||||||
<script src="/js/views/ticketGroups.js"></script>
|
<script src="/js/views/ticketGroups.js"></script>
|
||||||
<script src="/js/views/envConfig.js"></script>
|
<script src="/js/views/envConfig.js"></script>
|
||||||
|
<script src="/js/views/noteTemporanee.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const App = {
|
|||||||
if (saved) this.tabs = JSON.parse(saved);
|
if (saved) this.tabs = JSON.parse(saved);
|
||||||
const savedDrafts = localStorage.getItem('otrs_turbo_drafts');
|
const savedDrafts = localStorage.getItem('otrs_turbo_drafts');
|
||||||
if (savedDrafts) this.drafts = JSON.parse(savedDrafts);
|
if (savedDrafts) this.drafts = JSON.parse(savedDrafts);
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
this.renderTabs();
|
this.renderTabs();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -307,9 +307,15 @@ const App = {
|
|||||||
TicketListView.render();
|
TicketListView.render();
|
||||||
|
|
||||||
} else if (hash === '#/ricerca-spudorata') {
|
} else if (hash === '#/ricerca-spudorata') {
|
||||||
|
document.getElementById('nav-ricerca-spudorata')?.classList.add('active');
|
||||||
titleEl.textContent = 'Ricerca Spudorata';
|
titleEl.textContent = 'Ricerca Spudorata';
|
||||||
RicercaSpudorataView.render();
|
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') {
|
} else if (hash === '#/tickets/new') {
|
||||||
document.getElementById('nav-new-ticket')?.classList.add('active');
|
document.getElementById('nav-new-ticket')?.classList.add('active');
|
||||||
titleEl.textContent = 'Nuovo Ticket';
|
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 = `
|
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;">
|
<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>
|
<span>⏱ ${todayTime} / ${targetTime} m</span>
|
||||||
@@ -764,21 +796,36 @@ const App = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="timer-tooltip">
|
<div class="timer-tooltip">
|
||||||
<div class="timer-tooltip-border"></div>
|
<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>
|
<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>
|
||||||
<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="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:${percentage}%; background:linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); height:100%; border-radius:inherit; transition: width 0.3s ease;"></div>
|
<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>
|
</div>
|
||||||
${hasOverperformance ? `
|
${hasOverperformance ? (() => {
|
||||||
<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;">
|
const overImgSrc = (overImgName.startsWith('http://') || overImgName.startsWith('https://') || overImgName.startsWith('/')) ? overImgName : `img/${overImgName}`;
|
||||||
⚠️ Rilevata una overperformance allontanarsi dalla postazione immediatamente
|
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 <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>
|
||||||
` : ''}
|
`}
|
||||||
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
// Update sidebar counts as well
|
// Update sidebar counts as well
|
||||||
@@ -802,6 +849,14 @@ const App = {
|
|||||||
if (myBadge) {
|
if (myBadge) {
|
||||||
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
|
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) {
|
} catch (e) {
|
||||||
console.warn('Failed to update sidebar badges:', e);
|
console.warn('Failed to update sidebar badges:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -151,7 +151,8 @@ router.get('/config', async (req, res) => {
|
|||||||
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',
|
autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00',
|
||||||
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
|
helpdeskEmail: process.env.OTRS_MAIL_BCC || '',
|
||||||
agentEmail: agentEmail
|
agentEmail: agentEmail,
|
||||||
|
overperformanceImage: process.env.OVERPERFORMANCE_IMAGE || 'over9000.jpg'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -13,6 +13,7 @@ const emailRouter = require('./routes/email');
|
|||||||
const groupsRouter = require('./routes/groups');
|
const groupsRouter = require('./routes/groups');
|
||||||
const presetsRouter = require('./routes/presets');
|
const presetsRouter = require('./routes/presets');
|
||||||
const configRouter = require('./routes/config');
|
const configRouter = require('./routes/config');
|
||||||
|
const notesRouter = require('./routes/notes');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -33,6 +34,7 @@ app.use('/api/email', emailRouter);
|
|||||||
app.use('/api/groups', groupsRouter);
|
app.use('/api/groups', groupsRouter);
|
||||||
app.use('/api/presets', presetsRouter);
|
app.use('/api/presets', presetsRouter);
|
||||||
app.use('/api/config', configRouter);
|
app.use('/api/config', configRouter);
|
||||||
|
app.use('/api/notes', notesRouter);
|
||||||
|
|
||||||
// SPA fallback — serve index.html for all non-API routes
|
// SPA fallback — serve index.html for all non-API routes
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
|
|||||||