Compare commits
5
Commits
ce904756dd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6361484cd | ||
|
|
d32522bcbb | ||
|
|
2ca95e062a | ||
|
|
abb72d53c9 | ||
|
|
0b6907969d |
No files matched your search
@@ -40,6 +40,10 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
|||||||
// 2. ILIKE -> LIKE (MySQL LIKE is case-insensitive by default)
|
// 2. ILIKE -> LIKE (MySQL LIKE is case-insensitive by default)
|
||||||
translatedSql = translatedSql.replace(/\bILIKE\b/gi, 'LIKE');
|
translatedSql = translatedSql.replace(/\bILIKE\b/gi, 'LIKE');
|
||||||
|
|
||||||
|
// 2b. PostgreSQL regex ~* / ~ -> REGEXP
|
||||||
|
translatedSql = translatedSql.replace(/~\*/g, 'REGEXP');
|
||||||
|
translatedSql = translatedSql.replace(/~/g, 'REGEXP');
|
||||||
|
|
||||||
// 3. PostgreSQL string concatenation '||' -> CONCAT(...) in ticket number generator
|
// 3. PostgreSQL string concatenation '||' -> CONCAT(...) in ticket number generator
|
||||||
translatedSql = translatedSql.replace(/md5\(random\(\)::text\s*\|\|\s*clock_timestamp\(\)::text\)/gi, 'MD5(CONCAT(RAND(), NOW()))');
|
translatedSql = translatedSql.replace(/md5\(random\(\)::text\s*\|\|\s*clock_timestamp\(\)::text\)/gi, 'MD5(CONCAT(RAND(), NOW()))');
|
||||||
|
|
||||||
|
|||||||
@@ -1232,6 +1232,31 @@ body {
|
|||||||
color: var(--accent-primary-hover);
|
color: var(--accent-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-right .btn svg {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-bar-right .btn:not(:disabled):hover svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Pagination ---- */
|
/* ---- Pagination ---- */
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3038,3 +3063,31 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ricerca Spudorata - Info Modal / Popover & Regex */
|
||||||
|
#spudorata-info-btn:hover {
|
||||||
|
background: var(--accent-primary) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 10px var(--accent-primary-glow);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spudorata-rules-popover {
|
||||||
|
animation: popoverFadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popoverFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spudorata-regex-toggle:hover {
|
||||||
|
border-color: var(--accent-primary) !important;
|
||||||
|
background: var(--bg-card-hover) !important;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
const RicercaSpudorataView = {
|
const RicercaSpudorataView = {
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
|
isRegex: false,
|
||||||
cachedTickets: null,
|
cachedTickets: null,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
perPage: 50,
|
perPage: 50,
|
||||||
@@ -29,6 +30,10 @@ const RicercaSpudorataView = {
|
|||||||
|
|
||||||
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
|
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
|
||||||
const qParam = urlParams.get('q');
|
const qParam = urlParams.get('q');
|
||||||
|
const regexParam = urlParams.get('regex');
|
||||||
|
if (regexParam !== null) {
|
||||||
|
this.isRegex = regexParam === '1' || regexParam === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
if (qParam && qParam !== this.searchTerm) {
|
if (qParam && qParam !== this.searchTerm) {
|
||||||
this.renderContainer(container);
|
this.renderContainer(container);
|
||||||
@@ -62,12 +67,54 @@ const RicercaSpudorataView = {
|
|||||||
renderContainer(container) {
|
renderContainer(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<!-- Hero Text Search Area -->
|
<!-- Hero Text Search Area -->
|
||||||
<div class="ricerca-spudorata-hero" style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); box-shadow: var(--shadow-md);">
|
<div class="ricerca-spudorata-hero" style="background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); box-shadow: var(--shadow-md); position: relative;">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-sm); flex-wrap:wrap; gap:10px;">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-sm); flex-wrap:wrap; gap:10px;">
|
||||||
<div style="display:flex; align-items:center; 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(235, 114, 110, 0.2), rgba(160, 65, 71, 0.3)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">🚀</div>
|
<div style="display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; background: linear-gradient(135deg, rgba(235, 114, 110, 0.2), rgba(160, 65, 71, 0.3)); border-radius: 50%; color: var(--accent-primary); font-size:1.2rem; font-weight:bold;">🚀</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap: 8px;">
|
||||||
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);">Ricerca Testuale Spudorata</h2>
|
<h2 style="margin:0; font-size:1.2rem; font-weight:700; color:var(--text-primary);">Ricerca Testuale Spudorata</h2>
|
||||||
|
<div style="position: relative; display: inline-flex; align-items: center;">
|
||||||
|
<button type="button" id="spudorata-info-btn" aria-label="Informazioni sulle regole avanzate e Regex" style="background: rgba(79, 70, 229, 0.1); border: 1px solid rgba(79, 70, 229, 0.25); border-radius: 50%; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: var(--accent-primary); padding: 0; transition: all 0.2s ease;">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Modal / Popover indicazioni regole avanzate -->
|
||||||
|
<div id="spudorata-info-modal" class="spudorata-rules-popover" style="display:none; position:absolute; top:calc(100% + 10px); left:0; width:460px; max-width:90vw; background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-lg); box-shadow:0 12px 32px rgba(0,0,0,0.22); z-index:1100; padding:18px; font-size:0.85rem; color:var(--text-primary); backdrop-filter:blur(12px);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; border-bottom:1px solid var(--border-subtle); padding-bottom:8px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:6px; font-weight:700; font-size:0.95rem; color:var(--accent-primary);">
|
||||||
|
<span>💡</span>
|
||||||
|
<span>Guida Ricerca & Regole Avanzate (Regex)</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); background:var(--bg-tertiary); padding:2px 6px; border-radius:4px;">Regole avanzate</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="line-height:1.45; color:var(--text-secondary); margin-bottom:12px;">
|
||||||
|
Puoi cercare normalmente per parole chiave o attivare l'opzione <strong>Regex</strong> per ricerche con espressioni regolari (POSIX / JavaScript) sia sul database che sui filtri in cache.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:10px 12px; margin-bottom:12px;">
|
||||||
|
<div style="font-weight:600; color:var(--text-primary); margin-bottom:6px; font-size:0.82rem;">Esempi di espressioni regolari:</div>
|
||||||
|
<ul style="margin:0; padding-left:18px; display:flex; flex-direction:column; gap:6px;">
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">vpn|firewall</code> — Trova ticket contenenti <em>vpn</em> <strong>oppure</strong> <em>firewall</em></li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">\b(errore|fatal)\b</code> — Trova parole esatte "errore" o "fatal"</li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">^INC-[0-9]+</code> — Ticket o codici che iniziano per "INC-" seguito da cifre</li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">host(name)?.*down</code> — Trova "host down" o "hostname down" anche con parole in mezzo</li>
|
||||||
|
<li><code style="background:rgba(79,70,229,0.12); color:var(--accent-primary); padding:1px 5px; border-radius:3px; font-family:monospace;">\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}</code> — Trova indirizzi IP nel corpo o nell'oggetto</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:0.78rem; color:var(--text-tertiary); display:flex; flex-direction:column; gap:4px;">
|
||||||
|
<div>⚡ <strong>Case-insensitive:</strong> Le ricerche con Regex non distinguono automaticamente tra maiuscole e minuscole.</div>
|
||||||
|
<div>⚡ <strong>Cache locale:</strong> Anche il campo <em>"Cerca in Cache"</em> rispetta la modalità Regex o il formato <code>/pattern/</code>.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
|
<p style="margin:2px 0 0 0; font-size:0.82rem; color:var(--text-secondary);">
|
||||||
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano <strong>istantaneamente in cache locale</strong> senza fare nuove richieste al server.
|
Esegui la ricerca nel testo sui ticket. I filtri applicati successivamente operano <strong>istantaneamente in cache locale</strong> senza fare nuove richieste al server.
|
||||||
</p>
|
</p>
|
||||||
@@ -78,11 +125,19 @@ const RicercaSpudorataView = {
|
|||||||
|
|
||||||
<div style="display:flex; align-items:center; gap: var(--space-sm); flex-wrap:wrap; margin-top: var(--space-md);">
|
<div style="display:flex; align-items:center; gap: var(--space-sm); flex-wrap:wrap; margin-top: var(--space-md);">
|
||||||
<div style="position:relative; flex:1; min-width:300px;">
|
<div style="position:relative; flex:1; min-width:300px;">
|
||||||
<input type="text" id="spudorata-search-input" class="form-input" placeholder="Cerca nel testo (oggetto, corpo, numero ticket, cliente)..." value="${App.escapeHtml(this.searchTerm)}" style="width:100%; height:42px; font-size:0.95rem; padding-left:14px; padding-right:36px; border-radius:var(--radius-md);" />
|
<input type="text" id="spudorata-search-input" class="form-input" placeholder="${this.isRegex ? 'Cerca con espressione regolare (es. vpn|firewall, ^INC-\\d+)...' : 'Cerca nel testo (oggetto, corpo, numero ticket, cliente)...'}" value="${App.escapeHtml(this.searchTerm)}" style="width:100%; height:42px; font-size:0.95rem; padding-left:14px; padding-right:36px; border-radius:var(--radius-md);" />
|
||||||
<button id="spudorata-input-clear" style="position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-muted); cursor:pointer; display:${this.searchTerm ? 'flex' : 'none'}; align-items:center; justify-content:center;" title="Cancella testo">
|
<button id="spudorata-input-clear" style="position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-muted); cursor:pointer; display:${this.searchTerm ? 'flex' : 'none'}; align-items:center; justify-content:center;" title="Cancella testo">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggle Regex -->
|
||||||
|
<label id="spudorata-regex-toggle" style="display:inline-flex; align-items:center; gap:6px; cursor:pointer; user-select:none; height:42px; padding:0 12px; background:var(--bg-secondary); border:1px solid ${this.isRegex ? 'var(--accent-primary)' : 'var(--border-light)'}; border-radius:var(--radius-md); font-size:0.85rem; font-weight:600; color:${this.isRegex ? 'var(--accent-primary)' : 'var(--text-secondary)'}; transition:all 0.15s ease;" title="Abilita ricerca tramite Regular Expression">
|
||||||
|
<input type="checkbox" id="spudorata-regex-checkbox" ${this.isRegex ? 'checked' : ''} style="cursor:pointer; accent-color:var(--accent-primary);" />
|
||||||
|
<span style="font-family:monospace; font-size:0.95rem; font-weight:700;">.*</span>
|
||||||
|
<span>Regex</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button class="btn btn-primary" id="spudorata-search-btn" style="height:42px; padding:0 var(--space-xl); font-size:0.9rem; font-weight:600; min-width:130px; display:flex; align-items:center; justify-content:center; gap:6px;">
|
<button class="btn btn-primary" id="spudorata-search-btn" style="height:42px; padding:0 var(--space-xl); font-size:0.9rem; font-weight:600; min-width:130px; display:flex; align-items:center; justify-content:center; gap:6px;">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
<span>Cerca</span>
|
<span>Cerca</span>
|
||||||
@@ -96,12 +151,10 @@ const RicercaSpudorataView = {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batch Actions Bar -->
|
<!-- Batch Actions Bar -->
|
||||||
<div class="batch-bar" id="batch-bar">
|
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
|
||||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Stato</span>
|
<span class="filter-label">Stato</span>
|
||||||
<select class="filter-select" id="batch-state">
|
<select class="filter-select" id="batch-state">
|
||||||
@@ -138,11 +191,40 @@ const RicercaSpudorataView = {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonna Destra: Pulsanti delle Azioni -->
|
||||||
|
<div class="batch-bar-right" style="display: flex; flex-direction: column; align-items: flex-end; gap: 6px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||||
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled title="Aggiungi a gruppo" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-merge" disabled title="Unisci Selezionati" style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<circle cx="18" cy="18" r="3"></circle>
|
||||||
|
<circle cx="6" cy="6" r="3"></circle>
|
||||||
|
<path d="M6 21V9a9 9 0 0 0 9 9"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled title="Apri ticket in schede" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<path d="M2 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9z"></path>
|
||||||
|
<path d="M6 3h12a2 2 0 0 1 2 2v12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-copy-tns">Copia numeri</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-cancel" title="Annulla selezione">Annulla</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
|
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
|
||||||
@@ -167,6 +249,15 @@ const RicercaSpudorataView = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isRegex) {
|
||||||
|
try {
|
||||||
|
new RegExp(query);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error('Espressione regolare non valida: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.searchTerm = query;
|
this.searchTerm = query;
|
||||||
this.isFetching = true;
|
this.isFetching = true;
|
||||||
|
|
||||||
@@ -175,13 +266,14 @@ const RicercaSpudorataView = {
|
|||||||
resultsArea.innerHTML = `
|
resultsArea.innerHTML = `
|
||||||
<div class="loading-screen" style="padding: 60px 0;">
|
<div class="loading-screen" style="padding: 60px 0;">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Ricerca testuale nel server in corso per "${App.escapeHtml(query)}"...</p>
|
<p>Ricerca testuale ${this.isRegex ? 'con Regex ' : ''}nel server in corso per "${App.escapeHtml(query)}"...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}&per_page=2000`);
|
const regexParam = this.isRegex ? '&is_regex=1' : '';
|
||||||
|
const data = await App.api(`/api/tickets?search=${encodeURIComponent(query)}${regexParam}&per_page=2000`);
|
||||||
this.cachedTickets = data.tickets || [];
|
this.cachedTickets = data.tickets || [];
|
||||||
this.otrsBaseUrl = data.otrsBaseUrl || '';
|
this.otrsBaseUrl = data.otrsBaseUrl || '';
|
||||||
this.hasSearched = true;
|
this.hasSearched = true;
|
||||||
@@ -189,7 +281,7 @@ const RicercaSpudorataView = {
|
|||||||
this.selectedIds.clear();
|
this.selectedIds.clear();
|
||||||
this.selectedOrder = [];
|
this.selectedOrder = [];
|
||||||
|
|
||||||
Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"`);
|
Toast.success(`${this.cachedTickets.length} ticket caricati in cache locale per "${query}"${this.isRegex ? ' (Regex)' : ''}`);
|
||||||
this.renderContainer(document.getElementById('view-container'));
|
this.renderContainer(document.getElementById('view-container'));
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.renderLocalResults();
|
this.renderLocalResults();
|
||||||
@@ -334,8 +426,22 @@ const RicercaSpudorataView = {
|
|||||||
const s = Filters.state;
|
const s = Filters.state;
|
||||||
|
|
||||||
if (s.sub_search) {
|
if (s.sub_search) {
|
||||||
const q = String(s.sub_search).toLowerCase().trim();
|
const rawQ = String(s.sub_search).trim();
|
||||||
if (q) {
|
if (rawQ) {
|
||||||
|
let regexMatcher = null;
|
||||||
|
if (this.isRegex) {
|
||||||
|
try {
|
||||||
|
regexMatcher = new RegExp(rawQ, 'i');
|
||||||
|
} catch (e) {
|
||||||
|
// If invalid while typing, fallback or ignore
|
||||||
|
}
|
||||||
|
} else if (rawQ.startsWith('/') && rawQ.length > 2 && rawQ.endsWith('/')) {
|
||||||
|
try {
|
||||||
|
regexMatcher = new RegExp(rawQ.slice(1, -1), 'i');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qLower = rawQ.toLowerCase();
|
||||||
list = list.filter(t => {
|
list = list.filter(t => {
|
||||||
const fields = [
|
const fields = [
|
||||||
t.tn,
|
t.tn,
|
||||||
@@ -352,7 +458,10 @@ const RicercaSpudorataView = {
|
|||||||
t.customer_id,
|
t.customer_id,
|
||||||
t.customer_user_id
|
t.customer_user_id
|
||||||
];
|
];
|
||||||
return fields.some(f => f && String(f).toLowerCase().includes(q));
|
if (regexMatcher) {
|
||||||
|
return fields.some(f => f && regexMatcher.test(String(f)));
|
||||||
|
}
|
||||||
|
return fields.some(f => f && String(f).toLowerCase().includes(qLower));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,6 +611,78 @@ const RicercaSpudorataView = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regexCheckbox = document.getElementById('spudorata-regex-checkbox');
|
||||||
|
if (regexCheckbox) {
|
||||||
|
regexCheckbox.addEventListener('change', () => {
|
||||||
|
this.isRegex = regexCheckbox.checked;
|
||||||
|
const toggleLabel = document.getElementById('spudorata-regex-toggle');
|
||||||
|
if (toggleLabel) {
|
||||||
|
toggleLabel.style.borderColor = this.isRegex ? 'var(--accent-primary)' : 'var(--border-light)';
|
||||||
|
toggleLabel.style.color = this.isRegex ? 'var(--accent-primary)' : 'var(--text-secondary)';
|
||||||
|
}
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.placeholder = this.isRegex
|
||||||
|
? 'Cerca con espressione regolare (es. vpn|firewall, ^INC-\\d+)...'
|
||||||
|
: 'Cerca nel testo (oggetto, corpo, numero ticket, cliente)...';
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
// If we already have cached tickets, update the local filtered view immediately
|
||||||
|
if (this.hasSearched && this.cachedTickets) {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.renderLocalResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info button & hover modal
|
||||||
|
const infoBtn = document.getElementById('spudorata-info-btn');
|
||||||
|
const infoModal = document.getElementById('spudorata-info-modal');
|
||||||
|
if (infoBtn && infoModal) {
|
||||||
|
let closeTimeout = null;
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
if (closeTimeout) {
|
||||||
|
clearTimeout(closeTimeout);
|
||||||
|
closeTimeout = null;
|
||||||
|
}
|
||||||
|
infoModal.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModalWithDelay = () => {
|
||||||
|
if (closeTimeout) clearTimeout(closeTimeout);
|
||||||
|
closeTimeout = setTimeout(() => {
|
||||||
|
infoModal.style.display = 'none';
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
infoBtn.addEventListener('mouseenter', openModal);
|
||||||
|
infoBtn.addEventListener('mouseleave', closeModalWithDelay);
|
||||||
|
|
||||||
|
infoModal.addEventListener('mouseenter', () => {
|
||||||
|
if (closeTimeout) {
|
||||||
|
clearTimeout(closeTimeout);
|
||||||
|
closeTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
infoModal.addEventListener('mouseleave', closeModalWithDelay);
|
||||||
|
|
||||||
|
// Also toggle on click for touch/mobile devices or explicit clicks
|
||||||
|
infoBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (infoModal.style.display === 'block') {
|
||||||
|
infoModal.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
openModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!infoBtn.contains(e.target) && !infoModal.contains(e.target)) {
|
||||||
|
infoModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (resetCacheBtn) {
|
if (resetCacheBtn) {
|
||||||
resetCacheBtn.addEventListener('click', () => {
|
resetCacheBtn.addEventListener('click', () => {
|
||||||
this.invalidateCache();
|
this.invalidateCache();
|
||||||
|
|||||||
+103
-20
@@ -12,16 +12,20 @@ const TicketDetailView = {
|
|||||||
if (!this.ticketId) return;
|
if (!this.ticketId) return;
|
||||||
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
|
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
|
||||||
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
|
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
|
||||||
const time_unit = document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '';
|
const time_unit = (document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '') ||
|
||||||
|
(document.getElementById('note-time-units-bottom') ? document.getElementById('note-time-units-bottom').value.trim() : '');
|
||||||
const ticket_state_id = document.getElementById('note-ticket-state') ? document.getElementById('note-ticket-state').value : '';
|
const ticket_state_id = document.getElementById('note-ticket-state') ? document.getElementById('note-ticket-state').value : '';
|
||||||
|
|
||||||
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0 || ticket_state_id !== '') {
|
const note_create_time = document.getElementById('note-create-time') ? document.getElementById('note-create-time').value : '';
|
||||||
|
|
||||||
|
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0 || ticket_state_id !== '' || note_create_time !== '') {
|
||||||
App.saveDraft(this.ticketId, {
|
App.saveDraft(this.ticketId, {
|
||||||
type: 'note',
|
type: 'note',
|
||||||
body,
|
body,
|
||||||
subject,
|
subject,
|
||||||
time_unit,
|
time_unit,
|
||||||
ticket_state_id,
|
ticket_state_id,
|
||||||
|
note_create_time,
|
||||||
attachments: [...this.noteAttachments]
|
attachments: [...this.noteAttachments]
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -3587,7 +3591,11 @@ const TicketDetailView = {
|
|||||||
<div class="upload-file-list" id="note-file-list" style="margin-bottom: var(--space-sm);"></div>
|
<div class="upload-file-list" id="note-file-list" style="margin-bottom: var(--space-sm);"></div>
|
||||||
|
|
||||||
<div style="display:flex; gap:var(--space-md); justify-content:space-between; align-items:center; flex-wrap:wrap;">
|
<div style="display:flex; gap:var(--space-md); justify-content:space-between; align-items:center; flex-wrap:wrap;">
|
||||||
<div>
|
<div style="display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
||||||
|
<div style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<label for="note-time-units-bottom" style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Tempo (min):</label>
|
||||||
|
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units-bottom" placeholder="minuti" style="width:90px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.85rem;" />
|
||||||
|
</div>
|
||||||
<input type="file" id="note-attachments" multiple style="display:none;" />
|
<input type="file" id="note-attachments" multiple style="display:none;" />
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
|
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3991,9 +3999,15 @@ const TicketDetailView = {
|
|||||||
if (document.getElementById('note-time-units')) {
|
if (document.getElementById('note-time-units')) {
|
||||||
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
|
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
|
||||||
}
|
}
|
||||||
|
if (document.getElementById('note-time-units-bottom')) {
|
||||||
|
document.getElementById('note-time-units-bottom').value = noteDraft.time_unit || '';
|
||||||
|
}
|
||||||
if (document.getElementById('note-ticket-state') && noteDraft.ticket_state_id) {
|
if (document.getElementById('note-ticket-state') && noteDraft.ticket_state_id) {
|
||||||
document.getElementById('note-ticket-state').value = noteDraft.ticket_state_id;
|
document.getElementById('note-ticket-state').value = noteDraft.ticket_state_id;
|
||||||
}
|
}
|
||||||
|
if (document.getElementById('note-create-time') && noteDraft.note_create_time) {
|
||||||
|
document.getElementById('note-create-time').value = noteDraft.note_create_time;
|
||||||
|
}
|
||||||
this.noteAttachments = noteDraft.attachments || [];
|
this.noteAttachments = noteDraft.attachments || [];
|
||||||
this.updateNoteAttachmentList();
|
this.updateNoteAttachmentList();
|
||||||
}
|
}
|
||||||
@@ -4024,6 +4038,7 @@ const TicketDetailView = {
|
|||||||
const customerIdInput = document.getElementById('qe-customer-id');
|
const customerIdInput = document.getElementById('qe-customer-id');
|
||||||
const noteSubjectInput = document.getElementById('note-subject');
|
const noteSubjectInput = document.getElementById('note-subject');
|
||||||
const noteTimeUnitsInput = document.getElementById('note-time-units');
|
const noteTimeUnitsInput = document.getElementById('note-time-units');
|
||||||
|
const noteTimeUnitsBottomInput = document.getElementById('note-time-units-bottom');
|
||||||
const noteCreateTimeInput = document.getElementById('note-create-time');
|
const noteCreateTimeInput = document.getElementById('note-create-time');
|
||||||
|
|
||||||
const checkChanges = () => {
|
const checkChanges = () => {
|
||||||
@@ -4047,7 +4062,8 @@ const TicketDetailView = {
|
|||||||
const noteBodyText = (this.noteQuill ? this.noteQuill.root.innerText : '').trim();
|
const noteBodyText = (this.noteQuill ? this.noteQuill.root.innerText : '').trim();
|
||||||
if (noteBodyText.length > 0) hasChanges = true;
|
if (noteBodyText.length > 0) hasChanges = true;
|
||||||
|
|
||||||
const noteTimeUnitsVal = parseFloat(noteTimeUnitsInput ? noteTimeUnitsInput.value : '0') || 0;
|
const timeValStr = (noteTimeUnitsInput ? noteTimeUnitsInput.value : '') || (noteTimeUnitsBottomInput ? noteTimeUnitsBottomInput.value : '');
|
||||||
|
const noteTimeUnitsVal = parseFloat(timeValStr || '0') || 0;
|
||||||
if (noteTimeUnitsVal > 0) hasChanges = true;
|
if (noteTimeUnitsVal > 0) hasChanges = true;
|
||||||
|
|
||||||
if (noteCreateTimeInput && noteCreateTimeInput.value) hasChanges = true;
|
if (noteCreateTimeInput && noteCreateTimeInput.value) hasChanges = true;
|
||||||
@@ -4065,10 +4081,38 @@ const TicketDetailView = {
|
|||||||
noteSubjectInput.addEventListener('input', checkChanges);
|
noteSubjectInput.addEventListener('input', checkChanges);
|
||||||
}
|
}
|
||||||
if (noteTimeUnitsInput) {
|
if (noteTimeUnitsInput) {
|
||||||
noteTimeUnitsInput.addEventListener('input', checkChanges);
|
noteTimeUnitsInput.addEventListener('input', () => {
|
||||||
|
if (noteTimeUnitsBottomInput && noteTimeUnitsBottomInput.value !== noteTimeUnitsInput.value) {
|
||||||
|
noteTimeUnitsBottomInput.value = noteTimeUnitsInput.value;
|
||||||
|
}
|
||||||
|
checkChanges();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (noteTimeUnitsBottomInput) {
|
||||||
|
noteTimeUnitsBottomInput.addEventListener('input', () => {
|
||||||
|
if (noteTimeUnitsInput && noteTimeUnitsInput.value !== noteTimeUnitsBottomInput.value) {
|
||||||
|
noteTimeUnitsInput.value = noteTimeUnitsBottomInput.value;
|
||||||
|
}
|
||||||
|
checkChanges();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (noteCreateTimeInput) {
|
if (noteCreateTimeInput) {
|
||||||
noteCreateTimeInput.addEventListener('change', checkChanges);
|
noteCreateTimeInput.addEventListener('change', checkChanges);
|
||||||
|
|
||||||
|
const setDefaultNoteTime = () => {
|
||||||
|
if (!noteCreateTimeInput.value && ticket && ticket.create_time) {
|
||||||
|
const createDate = new Date(ticket.create_time);
|
||||||
|
if (!isNaN(createDate.getTime())) {
|
||||||
|
const plus5Min = new Date(createDate.getTime() + 5 * 60 * 1000);
|
||||||
|
const localISO = new Date(plus5Min.getTime() - plus5Min.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
|
noteCreateTimeInput.value = localISO;
|
||||||
|
checkChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
noteCreateTimeInput.addEventListener('focus', setDefaultNoteTime);
|
||||||
|
noteCreateTimeInput.addEventListener('click', setDefaultNoteTime);
|
||||||
}
|
}
|
||||||
if (this.noteQuill) {
|
if (this.noteQuill) {
|
||||||
this.noteQuill.on('text-change', checkChanges);
|
this.noteQuill.on('text-change', checkChanges);
|
||||||
@@ -4093,6 +4137,7 @@ const TicketDetailView = {
|
|||||||
if (noteSubjectInput) noteSubjectInput.value = '';
|
if (noteSubjectInput) noteSubjectInput.value = '';
|
||||||
if (this.noteQuill) this.noteQuill.root.innerHTML = '';
|
if (this.noteQuill) this.noteQuill.root.innerHTML = '';
|
||||||
if (noteTimeUnitsInput) noteTimeUnitsInput.value = '';
|
if (noteTimeUnitsInput) noteTimeUnitsInput.value = '';
|
||||||
|
if (noteTimeUnitsBottomInput) noteTimeUnitsBottomInput.value = '';
|
||||||
if (noteCreateTimeInput) noteCreateTimeInput.value = '';
|
if (noteCreateTimeInput) noteCreateTimeInput.value = '';
|
||||||
this.noteAttachments = [];
|
this.noteAttachments = [];
|
||||||
this.updateNoteAttachmentList();
|
this.updateNoteAttachmentList();
|
||||||
@@ -4177,36 +4222,53 @@ const TicketDetailView = {
|
|||||||
customerSearchInput.addEventListener('input', () => {
|
customerSearchInput.addEventListener('input', () => {
|
||||||
clearTimeout(customerDebounce);
|
clearTimeout(customerDebounce);
|
||||||
const q = customerSearchInput.value.trim();
|
const q = customerSearchInput.value.trim();
|
||||||
|
const origCustFirst = (this.originalValues['customer_first'] || '').trim();
|
||||||
|
const origCustLast = (this.originalValues['customer_last'] || '').trim();
|
||||||
|
const origCustName = origCustFirst ? `${origCustFirst} ${origCustLast}`.trim() : (this.originalValues['customer_user_id'] || '').trim();
|
||||||
|
|
||||||
|
if (q !== origCustName) {
|
||||||
if (customerUserIdInput) customerUserIdInput.value = '';
|
if (customerUserIdInput) customerUserIdInput.value = '';
|
||||||
if (customerIdInput) customerIdInput.value = '';
|
if (customerIdInput) customerIdInput.value = '';
|
||||||
|
} else {
|
||||||
|
if (customerUserIdInput) customerUserIdInput.value = this.originalValues['customer_user_id'] || '';
|
||||||
|
if (customerIdInput) customerIdInput.value = this.originalValues['customer_id'] || '';
|
||||||
|
}
|
||||||
checkChanges();
|
checkChanges();
|
||||||
|
|
||||||
customerDebounce = setTimeout(async () => {
|
customerDebounce = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const customers = await App.api(`/api/customers/search?q=${encodeURIComponent(q)}`);
|
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||||||
if (!customers || customers.length === 0) {
|
if (!users || users.length === 0) {
|
||||||
customerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default; padding: 6px 12px; font-size:0.78rem;">Nessun cliente trovato</div>';
|
customerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default; padding: 6px 12px; font-size:0.78rem;">Nessun cliente trovato</div>';
|
||||||
customerSuggestionsDiv.style.display = 'block';
|
customerSuggestionsDiv.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
customerSuggestionsDiv.innerHTML = customers.map(c => `
|
customerSuggestionsDiv.innerHTML = users.map(u => {
|
||||||
<div class="autocomplete-suggestion-item" data-user-id="${App.escapeHtml(c.customer_user_id)}" data-customer-id="${App.escapeHtml(c.customer_id || c.customer_user_id)}" data-name="${App.escapeHtml(c.first_name ? `${c.first_name} ${c.last_name}` : c.customer_user_id)}" style="padding: 6px 12px; font-size: 0.78rem; cursor: pointer;">
|
const fullName = (u.first_name || u.last_name) ? `${u.first_name || ''} ${u.last_name || ''}`.trim() : (u.login || '');
|
||||||
<strong>${App.escapeHtml(c.first_name ? `${c.first_name} ${c.last_name}` : c.customer_user_id)}</strong>
|
return `
|
||||||
${c.email ? `<span style="color:var(--text-muted); margin-left:6px;">(${App.escapeHtml(c.email)})</span>` : ''}
|
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login || '')}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(fullName)}" style="padding: 6px 12px; font-size: 0.78rem; cursor: pointer;">
|
||||||
|
<strong>${App.escapeHtml(fullName)}</strong>
|
||||||
|
<span style="color:var(--text-muted); margin-left:6px; font-size:0.75rem;">(Login: ${App.escapeHtml(u.login || '')}${u.customer_id ? ` | ${App.escapeHtml(u.customer_id)}` : ''})</span>
|
||||||
|
${u.email ? `<div style="color:var(--text-secondary); font-size:0.73rem;">${App.escapeHtml(u.email)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
customerSuggestionsDiv.style.display = 'block';
|
customerSuggestionsDiv.style.display = 'block';
|
||||||
|
|
||||||
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
const selectCustomerItem = (item) => {
|
||||||
if (item.dataset.userId) {
|
|
||||||
item.addEventListener('mousedown', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
customerSearchInput.value = item.dataset.name;
|
customerSearchInput.value = item.dataset.name;
|
||||||
if (customerUserIdInput) customerUserIdInput.value = item.dataset.userId;
|
if (customerUserIdInput) customerUserIdInput.value = item.dataset.login;
|
||||||
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
|
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
|
||||||
customerSuggestionsDiv.style.display = 'none';
|
customerSuggestionsDiv.style.display = 'none';
|
||||||
checkChanges();
|
checkChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||||
|
if (item.dataset.login) {
|
||||||
|
item.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectCustomerItem(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4216,6 +4278,18 @@ const TicketDetailView = {
|
|||||||
}, 150);
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (App.setupAutocompleteKeyboardNavigation) {
|
||||||
|
App.setupAutocompleteKeyboardNavigation(customerSearchInput, customerSuggestionsDiv, (item) => {
|
||||||
|
if (item && item.dataset.login) {
|
||||||
|
customerSearchInput.value = item.dataset.name;
|
||||||
|
if (customerUserIdInput) customerUserIdInput.value = item.dataset.login;
|
||||||
|
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
|
||||||
|
customerSuggestionsDiv.style.display = 'none';
|
||||||
|
checkChanges();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
customerSearchInput.addEventListener('focus', () => {
|
customerSearchInput.addEventListener('focus', () => {
|
||||||
customerSearchInput.dispatchEvent(new Event('input'));
|
customerSearchInput.dispatchEvent(new Event('input'));
|
||||||
});
|
});
|
||||||
@@ -4246,9 +4320,18 @@ const TicketDetailView = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
|
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
|
||||||
if (customerSearch && customerUserIdInput && !customerUserIdInput.value) {
|
const origCustFirst = (this.originalValues['customer_first'] || '').trim();
|
||||||
customerUserIdInput.value = customerSearch;
|
const origCustLast = (this.originalValues['customer_last'] || '').trim();
|
||||||
if (customerIdInput) customerIdInput.value = customerSearch;
|
const origCustName = origCustFirst ? `${origCustFirst} ${origCustLast}`.trim() : (this.originalValues['customer_user_id'] || '').trim();
|
||||||
|
|
||||||
|
if (customerSearchInput) {
|
||||||
|
if (!customerSearch) {
|
||||||
|
if (customerUserIdInput) customerUserIdInput.value = '';
|
||||||
|
if (customerIdInput) customerIdInput.value = '';
|
||||||
|
} else if (customerSearch === origCustName) {
|
||||||
|
if (customerUserIdInput) customerUserIdInput.value = this.originalValues['customer_user_id'] || '';
|
||||||
|
if (customerIdInput) customerIdInput.value = this.originalValues['customer_id'] || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check metadata updates
|
// Check metadata updates
|
||||||
|
|||||||
@@ -250,12 +250,11 @@ const TicketListView = {
|
|||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<!-- Batch Actions Bar -->
|
<!-- Batch Actions Bar -->
|
||||||
<div class="batch-bar" id="batch-bar">
|
<!-- Batch Actions Bar -->
|
||||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
|
||||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Stato</span>
|
<span class="filter-label">Stato</span>
|
||||||
<select class="filter-select" id="batch-state">
|
<select class="filter-select" id="batch-state">
|
||||||
@@ -292,11 +291,40 @@ const TicketListView = {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonna Destra: Pulsanti delle Azioni -->
|
||||||
|
<div class="batch-bar-right" style="display: flex; flex-direction: column; align-items: flex-end; gap: 6px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||||
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled title="Aggiungi a gruppo" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-merge" disabled title="Unisci Selezionati" style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<circle cx="18" cy="18" r="3"></circle>
|
||||||
|
<circle cx="6" cy="6" r="3"></circle>
|
||||||
|
<path d="M6 21V9a9 9 0 0 0 9 9"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled title="Apri ticket in schede" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||||
|
<path d="M2 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9z"></path>
|
||||||
|
<path d="M6 3h12a2 2 0 0 1 2 2v12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-copy-tns">Copia numeri</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" id="batch-cancel" title="Annulla selezione">Annulla</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${Filters.renderBar(App.lookups)}
|
${Filters.renderBar(App.lookups)}
|
||||||
|
|||||||
@@ -333,8 +333,21 @@ const TimeAccountingView = {
|
|||||||
borderStyle = '1px solid var(--border-subtle)';
|
borderStyle = '1px solid var(--border-subtle)';
|
||||||
textColor = 'var(--accent-primary)';
|
textColor = 'var(--accent-primary)';
|
||||||
}
|
}
|
||||||
|
} else if (dayMins === 0) {
|
||||||
|
// Giorno lavorativo senza consuntivazioni: neutro / bianco
|
||||||
|
if (isSelected) {
|
||||||
|
bgStyle = 'var(--accent-primary)';
|
||||||
|
borderStyle = '1px solid var(--accent-primary)';
|
||||||
|
textColor = '#ffffff';
|
||||||
|
badgeBg = 'rgba(255,255,255,0.25)';
|
||||||
|
badgeColor = '#ffffff';
|
||||||
|
} else if (isToday) {
|
||||||
|
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||||
|
borderStyle = '1px solid var(--border-subtle)';
|
||||||
|
textColor = 'var(--accent-primary)';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Working days (configured in WORK_DAYS)
|
// Working days con consuntivazione (> 0 min)
|
||||||
if (isTargetReached) {
|
if (isTargetReached) {
|
||||||
// Verdino (green)
|
// Verdino (green)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -478,6 +491,18 @@ const TimeAccountingView = {
|
|||||||
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||||
textColor = 'var(--accent-primary)';
|
textColor = 'var(--accent-primary)';
|
||||||
}
|
}
|
||||||
|
} else if (dayMins === 0) {
|
||||||
|
// Giorno lavorativo senza ore consuntivate: neutro / non evidenziato in rosso
|
||||||
|
if (isSelected) {
|
||||||
|
bgStyle = 'var(--accent-primary)';
|
||||||
|
borderStyle = '2px solid var(--accent-primary)';
|
||||||
|
textColor = '#ffffff';
|
||||||
|
badgeBg = 'rgba(255,255,255,0.25)';
|
||||||
|
badgeColor = '#ffffff';
|
||||||
|
} else if (isToday) {
|
||||||
|
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||||
|
textColor = 'var(--accent-primary)';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isTargetReached) {
|
if (isTargetReached) {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
|
|||||||
@@ -296,6 +296,12 @@ router.get('/customer-companies/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Alias /api/customers/search -> /api/customer-users/search for backwards compatibility
|
||||||
|
router.get('/customers/search', (req, res, next) => {
|
||||||
|
req.url = '/customer-users/search' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
|
||||||
|
router.handle(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/customer-users/search — Search customer users (supports LDAP via REST, fallbacks to DB)
|
// GET /api/customer-users/search — Search customer users (supports LDAP via REST, fallbacks to DB)
|
||||||
router.get('/customer-users/search', async (req, res) => {
|
router.get('/customer-users/search', async (req, res) => {
|
||||||
const { q = '', customer_company_id } = req.query;
|
const { q = '', customer_company_id } = req.query;
|
||||||
|
|||||||
+70
-8
@@ -212,6 +212,30 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const searchTerm = search || sub_search;
|
const searchTerm = search || sub_search;
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
|
const isRegex = req.query.is_regex === 'true' || req.query.is_regex === '1';
|
||||||
|
if (isRegex) {
|
||||||
|
// Validate regex pattern to prevent DB syntax error
|
||||||
|
try {
|
||||||
|
new RegExp(searchTerm);
|
||||||
|
} catch (regexErr) {
|
||||||
|
return res.status(400).json({ error: `Espressione regolare non valida: ${regexErr.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(`(
|
||||||
|
t.title ~* $${paramIdx} OR
|
||||||
|
t.tn ~* $${paramIdx} OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM article art
|
||||||
|
LEFT JOIN article_data_mime adm ON art.id = adm.article_id
|
||||||
|
WHERE art.ticket_id = t.id AND (
|
||||||
|
adm.a_subject ~* $${paramIdx} OR
|
||||||
|
adm.a_body ~* $${paramIdx}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`);
|
||||||
|
params.push(searchTerm);
|
||||||
|
paramIdx++;
|
||||||
|
} else {
|
||||||
conditions.push(`(
|
conditions.push(`(
|
||||||
t.title ILIKE $${paramIdx} OR
|
t.title ILIKE $${paramIdx} OR
|
||||||
t.tn ILIKE $${paramIdx} OR
|
t.tn ILIKE $${paramIdx} OR
|
||||||
@@ -227,6 +251,7 @@ router.get('/', async (req, res) => {
|
|||||||
params.push(`%${searchTerm}%`);
|
params.push(`%${searchTerm}%`);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0
|
const whereClause = conditions.length > 0
|
||||||
? 'WHERE ' + conditions.join(' AND ')
|
? 'WHERE ' + conditions.join(' AND ')
|
||||||
@@ -387,7 +412,11 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
// 1. Try to read from local SQLite cache in internal.db
|
// 1. Try to read from local SQLite cache in internal.db
|
||||||
let cachedUser = null;
|
let cachedUser = null;
|
||||||
try {
|
try {
|
||||||
cachedUser = db.prepare('SELECT email, first_name, last_name, phone FROM customer_user_cache WHERE login = ?').get(ticket.customer_user_id);
|
cachedUser = db.prepare(`
|
||||||
|
SELECT login, email, first_name, last_name, phone
|
||||||
|
FROM customer_user_cache
|
||||||
|
WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE
|
||||||
|
`).get(ticket.customer_user_id, ticket.customer_user_id);
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
console.warn('[LDAP Cache Detail] SQLite cache read failed:', dbErr.message);
|
console.warn('[LDAP Cache Detail] SQLite cache read failed:', dbErr.message);
|
||||||
}
|
}
|
||||||
@@ -398,8 +427,20 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
ticket.customer_email = cachedUser.email || '';
|
ticket.customer_email = cachedUser.email || '';
|
||||||
ticket.customer_phone = cachedUser.phone || '';
|
ticket.customer_phone = cachedUser.phone || '';
|
||||||
} else {
|
} else {
|
||||||
// 2. Fallback to live API fetch
|
// 2. Fallback to live API fetch (only if login doesn't contain spaces)
|
||||||
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: ticket.customer_user_id });
|
let targetLogin = ticket.customer_user_id;
|
||||||
|
if (targetLogin.includes(' ')) {
|
||||||
|
try {
|
||||||
|
const sRes = await otrsRequest('POST', '/CustomerUserSearch', { Search: `*${targetLogin}*`, Valid: 1 });
|
||||||
|
const sData = (sRes && sRes.Data) || sRes;
|
||||||
|
if (sData && sData.CustomerUserID) {
|
||||||
|
targetLogin = Array.isArray(sData.CustomerUserID) ? sData.CustomerUserID[0] : sData.CustomerUserID;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLogin && !targetLogin.includes(' ')) {
|
||||||
|
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: targetLogin });
|
||||||
const getData = (getResult && getResult.Data) || getResult;
|
const getData = (getResult && getResult.Data) || getResult;
|
||||||
if (getData && getData.CustomerUser) {
|
if (getData && getData.CustomerUser) {
|
||||||
const u = getData.CustomerUser;
|
const u = getData.CustomerUser;
|
||||||
@@ -432,6 +473,7 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (apiErr) {
|
} catch (apiErr) {
|
||||||
console.warn(`Failed to fetch LDAP customer details for detail view:`, apiErr.message);
|
console.warn(`Failed to fetch LDAP customer details for detail view:`, apiErr.message);
|
||||||
}
|
}
|
||||||
@@ -940,9 +982,19 @@ router.patch('/:id', async (req, res) => {
|
|||||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
||||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||||
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
||||||
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
|
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') {
|
||||||
|
try {
|
||||||
|
const match = db.prepare(`SELECT login, customer_id FROM customer_user_cache WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE`).get(updates.customer_user_id, updates.customer_user_id);
|
||||||
|
if (match) {
|
||||||
|
updates.customer_user_id = match.login;
|
||||||
|
if (!updates.customer_id && match.customer_id) {
|
||||||
|
updates.customer_id = match.customer_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
ticketFields.CustomerUser = updates.customer_user_id;
|
||||||
|
}
|
||||||
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
|
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
|
||||||
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') ticketFields.CustomerUser = updates.customer_user_id;
|
|
||||||
|
|
||||||
// If ticketFields is empty but we have a note or timeUnit to log, supply a dummy field (StateID) so Ticket is not empty
|
// If ticketFields is empty but we have a note or timeUnit to log, supply a dummy field (StateID) so Ticket is not empty
|
||||||
if (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
|
if (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
|
||||||
@@ -1673,9 +1725,19 @@ router.patch('/batch/update', async (req, res) => {
|
|||||||
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
||||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||||
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
||||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') {
|
||||||
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
|
try {
|
||||||
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
|
const match = db.prepare(`SELECT login, customer_id FROM customer_user_cache WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE`).get(updates.customer_user_id, updates.customer_user_id);
|
||||||
|
if (match) {
|
||||||
|
updates.customer_user_id = match.login;
|
||||||
|
if (!updates.customer_id && match.customer_id) {
|
||||||
|
updates.customer_id = match.customer_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
ticketFields.CustomerUser = updates.customer_user_id;
|
||||||
|
}
|
||||||
|
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
|
||||||
|
|
||||||
// Auto sblocco check
|
// Auto sblocco check
|
||||||
if (updates.ticket_state_id) {
|
if (updates.ticket_state_id) {
|
||||||
|
|||||||
Reference in new issue
Block a user