Compare commits
9
Commits
a1b6d9333c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6361484cd | ||
|
|
d32522bcbb | ||
|
|
2ca95e062a | ||
|
|
abb72d53c9 | ||
|
|
0b6907969d | ||
|
|
ce904756dd | ||
|
|
107ba10689 | ||
|
|
60259427d3 | ||
|
|
dcc0456070 |
No files matched your search
@@ -40,6 +40,10 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||
// 2. ILIKE -> LIKE (MySQL LIKE is case-insensitive by default)
|
||||
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
|
||||
translatedSql = translatedSql.replace(/md5\(random\(\)::text\s*\|\|\s*clock_timestamp\(\)::text\)/gi, 'MD5(CONCAT(RAND(), NOW()))');
|
||||
|
||||
@@ -182,13 +186,6 @@ if (dbType === 'mysql' || dbType === 'mariadb') {
|
||||
options: '-c timezone=UTC', // Ensure connection timezone is UTC
|
||||
});
|
||||
|
||||
// Ensure connection timezone is UTC via query fallback
|
||||
pool.on('connect', (client) => {
|
||||
client.query("SET TIME ZONE 'UTC'").catch(err => {
|
||||
console.error('[DB] Errore nell\'impostazione della timezone UTC per Postgres:', err);
|
||||
});
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
});
|
||||
|
||||
@@ -1232,6 +1232,31 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -2779,3 +2804,290 @@ body {
|
||||
color: transparent !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
}
|
||||
|
||||
/* ---- Linux Terminal Console ---- */
|
||||
.console-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-md);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.console-window {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
height: 600px;
|
||||
max-height: 85vh;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
height: 38px;
|
||||
background: #1e1e2e;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.console-window-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-btn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.console-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.console-btn.btn-close { background: #ff5f56; border: 1px solid #e0443e; }
|
||||
.console-btn.btn-minimize { background: #ffbd2e; border: 1px solid #dea123; }
|
||||
.console-btn.btn-maximize { background: #27c93f; border: 1px solid #1aab29; }
|
||||
|
||||
.console-title {
|
||||
font-size: 0.8rem;
|
||||
color: #a6adc8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.console-badge {
|
||||
font-size: 0.7rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.console-body {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Themes */
|
||||
.theme-dark .console-window { background: #0f1419; color: #e6edf3; }
|
||||
.theme-dark .console-prompt .user-part { color: #38bdf8; font-weight: bold; }
|
||||
.theme-dark .console-prompt .path-part { color: #818cf8; }
|
||||
.theme-dark .cmd-highlight { color: #38bdf8; font-weight: bold; }
|
||||
.theme-dark .cmd-executed { color: #f43f5e; font-weight: bold; }
|
||||
|
||||
.theme-green .console-window { background: #051a05; color: #4ade80; }
|
||||
.theme-green .console-prompt .user-part { color: #22c55e; font-weight: bold; }
|
||||
.theme-green .console-prompt .path-part { color: #86efac; }
|
||||
.theme-green .cmd-highlight { color: #4ade80; font-weight: bold; }
|
||||
.theme-green .cmd-executed { color: #a7f3d0; font-weight: bold; }
|
||||
.theme-green .console-header { background: #082808; }
|
||||
|
||||
.theme-amber .console-window { background: #1a1202; color: #fbbf24; }
|
||||
.theme-amber .console-prompt .user-part { color: #f59e0b; font-weight: bold; }
|
||||
.theme-amber .console-prompt .path-part { color: #fde68a; }
|
||||
.theme-amber .cmd-highlight { color: #fbbf24; font-weight: bold; }
|
||||
.theme-amber .cmd-executed { color: #fef08a; font-weight: bold; }
|
||||
.theme-amber .console-header { background: #2a1d04; }
|
||||
|
||||
.theme-cyber .console-window { background: #0d0221; color: #00f5d4; }
|
||||
.theme-cyber .console-prompt .user-part { color: #7b2cbf; font-weight: bold; }
|
||||
.theme-cyber .console-prompt .path-part { color: #f72585; }
|
||||
.theme-cyber .cmd-highlight { color: #7209b7; font-weight: bold; }
|
||||
.theme-cyber .cmd-executed { color: #4cc9f0; font-weight: bold; }
|
||||
.theme-cyber .console-header { background: #19053d; }
|
||||
|
||||
.console-banner {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.console-welcome-text {
|
||||
margin-bottom: 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.console-line {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.console-output-line {
|
||||
margin-bottom: 8px;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.console-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.console-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.88rem;
|
||||
padding: 0;
|
||||
caret-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.text-success { color: #22c55e; }
|
||||
.text-danger { color: #ef4444; }
|
||||
.text-warning { color: #f59e0b; }
|
||||
.text-info { color: #3b82f6; }
|
||||
.text-muted { color: #64748b; }
|
||||
.text-accent { color: #38bdf8; font-weight: bold; }
|
||||
|
||||
.dir-item { color: #60a5fa; font-weight: bold; }
|
||||
.file-item { color: #e2e8f0; }
|
||||
|
||||
.console-help-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.console-help-table > div {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cmd-name {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
color: #38bdf8;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cmd-desc {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.neofetch-container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.neofetch-logo {
|
||||
color: #38bdf8;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.15;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.neofetch-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.console-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.console-table th {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 4px 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.console-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.badge-mini {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.matrix-stream {
|
||||
color: #22c55e;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.sl-train-box {
|
||||
color: #f59e0b;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
@@ -279,6 +279,7 @@
|
||||
<script src="/js/views/envConfig.js"></script>
|
||||
<script src="/js/views/userConfig.js"></script>
|
||||
<script src="/js/views/noteTemporanee.js"></script>
|
||||
<script src="/js/views/consoleView.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
+65
-4
@@ -68,6 +68,8 @@ const App = {
|
||||
pdf_save_edited: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||
ricerca_spudorata_open: { ctrl: true, alt: false, shift: true, key: 'f', enabled: true },
|
||||
ticket_list_open: { ctrl: true, alt: false, shift: true, key: 'Backspace', enabled: true },
|
||||
open_terminal_console: { ctrl: true, alt: true, shift: false, key: 't', enabled: true },
|
||||
add_ticket_tab: { ctrl: true, alt: true, shift: false, key: 's', enabled: true },
|
||||
};
|
||||
return defaults[id] || { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true };
|
||||
},
|
||||
@@ -92,13 +94,15 @@ const App = {
|
||||
const isShift = sc.shift ? e.shiftKey : !e.shiftKey;
|
||||
|
||||
let targetKey = sc.key.toLowerCase();
|
||||
let pressedKey = e.key.toLowerCase();
|
||||
let pressedKey = (e.key || '').toLowerCase();
|
||||
let codeKey = (e.code || '').replace(/^Key/, '').toLowerCase();
|
||||
|
||||
if (targetKey === 'space' && (pressedKey === ' ' || pressedKey === 'space')) {
|
||||
if (targetKey === 'space' && (pressedKey === ' ' || pressedKey === 'space' || codeKey === 'space')) {
|
||||
return isCtrlOrCmd && isAlt && isShift;
|
||||
}
|
||||
|
||||
return isCtrlOrCmd && isAlt && isShift && (pressedKey === targetKey);
|
||||
const matchesKey = (pressedKey === targetKey) || (codeKey === targetKey);
|
||||
return isCtrlOrCmd && isAlt && isShift && matchesKey;
|
||||
},
|
||||
|
||||
formatShortcutLabel(sc) {
|
||||
@@ -120,11 +124,18 @@ const App = {
|
||||
return text.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-primary); text-decoration:underline;">${url}</a>`);
|
||||
},
|
||||
|
||||
openTab(id, tn, title) {
|
||||
openTab(id, tn, title, notify = false) {
|
||||
const exists = this.tabs.find(t => String(t.id) === String(id));
|
||||
if (!exists) {
|
||||
this.tabs.push({ id, tn, title });
|
||||
this.saveTabs();
|
||||
if (notify && typeof Toast !== 'undefined') {
|
||||
Toast.success(`Ticket #${tn || id} aggiunto alle schede!`);
|
||||
}
|
||||
} else {
|
||||
if (notify && typeof Toast !== 'undefined') {
|
||||
Toast.info(`Ticket #${tn || id} è già nelle schede`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -302,6 +313,56 @@ const App = {
|
||||
e.preventDefault();
|
||||
window.location.hash = '#/tickets';
|
||||
}
|
||||
} else if (this.isShortcutTriggered(e, 'open_terminal_console')) {
|
||||
e.preventDefault();
|
||||
const consoleObj = window.ConsoleView || (typeof ConsoleView !== 'undefined' ? ConsoleView : null);
|
||||
if (consoleObj) {
|
||||
consoleObj.toggle();
|
||||
}
|
||||
} else if (this.isShortcutTriggered(e, 'add_ticket_tab')) {
|
||||
e.preventDefault();
|
||||
// 1. Check if in ticket detail
|
||||
const detailTabBtn = document.querySelector('.ticket-header .open-tab-btn, .ticket-detail .open-tab-btn');
|
||||
if (detailTabBtn) {
|
||||
detailTabBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if multiple tickets selected in list
|
||||
const selectedRows = document.querySelectorAll('.ticket-table tbody tr.selected, .results-table tbody tr.selected');
|
||||
if (selectedRows.length > 0) {
|
||||
let count = 0;
|
||||
selectedRows.forEach(row => {
|
||||
const tabBtn = row.querySelector('.open-tab-btn');
|
||||
if (tabBtn) {
|
||||
const id = tabBtn.dataset.id;
|
||||
const tn = tabBtn.dataset.tn;
|
||||
const title = tabBtn.dataset.title || '';
|
||||
if (id) {
|
||||
this.openTab(parseInt(id, 10), tn, title);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (count > 0 && typeof Toast !== 'undefined') {
|
||||
Toast.success(`${count} ticket aggiunti alle schede!`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Check hovered or focused row in any ticket table/list
|
||||
const hoveredRow = document.querySelector('.ticket-table tbody tr:hover, .results-table tbody tr:hover, .ticket-table tbody tr:focus-within, .results-table tbody tr:focus-within');
|
||||
if (hoveredRow) {
|
||||
const tabBtn = hoveredRow.querySelector('.open-tab-btn');
|
||||
if (tabBtn) {
|
||||
tabBtn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Toast !== 'undefined') {
|
||||
Toast.info('Seleziona o apri un ticket per aggiungerlo in scheda');
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* ConsoleView — Linux Terminal Simulation for OTRS Turbo
|
||||
* Shortcut: Ctrl + Alt + T
|
||||
*/
|
||||
const ConsoleView = {
|
||||
isOpen: false,
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
currentTheme: 'dark', // dark, green, amber, cyber
|
||||
activeAnimation: null,
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
},
|
||||
|
||||
open() {
|
||||
let modal = document.getElementById('linux-console-modal');
|
||||
if (!modal) {
|
||||
this.createModal();
|
||||
modal = document.getElementById('linux-console-modal');
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
this.isOpen = true;
|
||||
const input = document.getElementById('console-input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
const modal = document.getElementById('linux-console-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
this.stopAnimation();
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
createModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'linux-console-modal';
|
||||
overlay.className = 'console-modal-overlay theme-dark';
|
||||
overlay.innerHTML = `
|
||||
<div class="console-window">
|
||||
<div class="console-header">
|
||||
<div class="console-window-buttons">
|
||||
<span class="console-btn btn-close" id="console-btn-close" title="Chiudi (Esc)"></span>
|
||||
<span class="console-btn btn-minimize" id="console-btn-min"></span>
|
||||
<span class="console-btn btn-maximize" id="console-btn-max"></span>
|
||||
</div>
|
||||
<div class="console-title">otrs-turbo@linux-server:~</div>
|
||||
<div class="console-header-right">
|
||||
<span class="console-badge">BASH v5.2.15</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="console-body" id="console-body">
|
||||
<div class="console-welcome">
|
||||
<pre class="console-banner">
|
||||
____ _____ ____ ____ _____ _ _ ____ ____ ___
|
||||
/ ___|_ _| _ \\/ ___| |_ _| | | | _ \\/ __ ) / _ \\
|
||||
| | | | | |_) \\___ \\ | | | | | | |_) | _ \\| | | |
|
||||
| |___ | | | _ < ___) | | | | |_| | _ <| |_) | |_| |
|
||||
\\____| |_| |_| \\_\\____/ |_| \\___/|_| \\_\\____/ \\___/
|
||||
</pre>
|
||||
<div class="console-welcome-text">
|
||||
Benvenuto nella console di simulazione Linux per <strong>OTRS Turbo</strong>.<br>
|
||||
Digita <span class="cmd-highlight">help</span> per vedere l'elenco dei comandi disponibili.
|
||||
</div>
|
||||
</div>
|
||||
<div id="console-output"></div>
|
||||
<div class="console-input-row" id="console-input-row">
|
||||
<span class="console-prompt"><span class="user-part">agent@otrs-turbo</span>:<span class="path-part">~</span>$</span>
|
||||
<input type="text" id="console-input" class="console-input" autocomplete="off" spellcheck="false" autocorrect="off" autocapitalize="none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Event Bindings
|
||||
overlay.querySelector('#console-btn-close').addEventListener('click', () => this.close());
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) this.close();
|
||||
else {
|
||||
const input = document.getElementById('console-input');
|
||||
if (input && document.activeElement !== input) input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const input = overlay.querySelector('#console-input');
|
||||
input.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
||||
},
|
||||
|
||||
stopAnimation() {
|
||||
if (this.activeAnimation) {
|
||||
clearInterval(this.activeAnimation);
|
||||
this.activeAnimation = null;
|
||||
}
|
||||
},
|
||||
|
||||
handleKeyDown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
const input = e.target;
|
||||
const cmdText = input.value.trim();
|
||||
input.value = '';
|
||||
if (cmdText) {
|
||||
this.history.push(cmdText);
|
||||
this.historyIndex = this.history.length;
|
||||
this.executeCommand(cmdText);
|
||||
} else {
|
||||
this.printPrompt('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (this.history.length > 0 && this.historyIndex > 0) {
|
||||
this.historyIndex--;
|
||||
e.target.value = this.history[this.historyIndex];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (this.historyIndex < this.history.length - 1) {
|
||||
this.historyIndex++;
|
||||
e.target.value = this.history[this.historyIndex];
|
||||
} else {
|
||||
this.historyIndex = this.history.length;
|
||||
e.target.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
this.autocomplete(e.target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
|
||||
if (this.activeAnimation) {
|
||||
e.preventDefault();
|
||||
this.stopAnimation();
|
||||
this.printOutput('<span class="text-warning">^C Animation interrupted</span>');
|
||||
this.showInputRow();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
autocomplete(inputEl) {
|
||||
const val = inputEl.value.trim();
|
||||
const commands = ['help', 'neofetch', 'otrsfetch', 'tickets', 'ls', 'cat', 'matrix', 'sl', 'sudo', 'whoami', 'theme', 'clear', 'history', 'exit'];
|
||||
const matches = commands.filter(c => c.startsWith(val));
|
||||
if (matches.length === 1) {
|
||||
inputEl.value = matches[0] + ' ';
|
||||
} else if (matches.length > 1) {
|
||||
this.printOutput(`<span class="text-muted">${matches.join(' ')}</span>`);
|
||||
}
|
||||
},
|
||||
|
||||
printPrompt(cmdText) {
|
||||
const outputDiv = document.getElementById('console-output');
|
||||
const line = document.createElement('div');
|
||||
line.className = 'console-line';
|
||||
line.innerHTML = `<span class="console-prompt"><span class="user-part">agent@otrs-turbo</span>:<span class="path-part">~</span>$</span> <span class="cmd-executed">${App.escapeHtml(cmdText)}</span>`;
|
||||
outputDiv.appendChild(line);
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
printOutput(htmlContent) {
|
||||
const outputDiv = document.getElementById('console-output');
|
||||
const line = document.createElement('div');
|
||||
line.className = 'console-output-line';
|
||||
line.innerHTML = htmlContent;
|
||||
outputDiv.appendChild(line);
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
const body = document.getElementById('console-body');
|
||||
if (body) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
showInputRow() {
|
||||
const row = document.getElementById('console-input-row');
|
||||
if (row) row.style.display = 'flex';
|
||||
const input = document.getElementById('console-input');
|
||||
if (input) input.focus();
|
||||
},
|
||||
|
||||
hideInputRow() {
|
||||
const row = document.getElementById('console-input-row');
|
||||
if (row) row.style.display = 'none';
|
||||
},
|
||||
|
||||
async executeCommand(rawCmd) {
|
||||
this.stopAnimation();
|
||||
this.printPrompt(rawCmd);
|
||||
const parts = rawCmd.trim().split(/\s+/);
|
||||
const mainCmd = parts[0].toLowerCase();
|
||||
const args = parts.slice(1);
|
||||
|
||||
switch (mainCmd) {
|
||||
case 'help':
|
||||
this.cmdHelp();
|
||||
break;
|
||||
case 'clear':
|
||||
document.getElementById('console-output').innerHTML = '';
|
||||
break;
|
||||
case 'whoami':
|
||||
const agent = App.activeAgent || 'gabriele.cimaschi';
|
||||
this.printOutput(`<span class="text-success">${App.escapeHtml(agent)}</span> (OTRS Turbo Operator)`);
|
||||
break;
|
||||
case 'sudo':
|
||||
this.printOutput(`<span class="text-danger">agent is not in the sudoers file. This incident will be reported to OTRS Admins.</span>`);
|
||||
break;
|
||||
case 'ls':
|
||||
this.cmdLs();
|
||||
break;
|
||||
case 'cat':
|
||||
this.cmdCat(args[0]);
|
||||
break;
|
||||
case 'history':
|
||||
this.cmdHistory();
|
||||
break;
|
||||
case 'neofetch':
|
||||
case 'otrsfetch':
|
||||
await this.cmdNeofetch();
|
||||
break;
|
||||
case 'tickets':
|
||||
await this.cmdTickets();
|
||||
break;
|
||||
case 'theme':
|
||||
this.cmdTheme(args[0]);
|
||||
break;
|
||||
case 'matrix':
|
||||
this.cmdMatrix();
|
||||
break;
|
||||
case 'sl':
|
||||
this.cmdSl();
|
||||
break;
|
||||
case 'exit':
|
||||
this.close();
|
||||
break;
|
||||
default:
|
||||
this.printOutput(`<span class="text-danger">bash: ${App.escapeHtml(mainCmd)}: command not found.</span> Digita <span class="cmd-highlight">help</span> per la lista dei comandi.`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
cmdHelp() {
|
||||
const helpHtml = `
|
||||
<div class="console-help-table">
|
||||
<div><span class="cmd-name">help</span><span class="cmd-desc">Mostra questo messaggio di aiuto</span></div>
|
||||
<div><span class="cmd-name">neofetch</span> / <span class="cmd-name">otrsfetch</span><span class="cmd-desc">Visualizza informazioni ed ASCII art di OTRS Turbo</span></div>
|
||||
<div><span class="cmd-name">tickets</span><span class="cmd-desc">Elenca i ticket recenti ed i conteggi direttamente dal DB</span></div>
|
||||
<div><span class="cmd-name">ls</span><span class="cmd-desc">Elenca i file della directory corrente</span></div>
|
||||
<div><span class="cmd-name">cat <file></span><span class="cmd-desc">Legge il contenuto di un file virtuale</span></div>
|
||||
<div><span class="cmd-name">whoami</span><span class="cmd-desc">Mostra l'agente attualmente autenticato</span></div>
|
||||
<div><span class="cmd-name">theme <dark|green|amber|cyber></span><span class="cmd-desc">Cambia il tema cromatico del terminale</span></div>
|
||||
<div><span class="cmd-name">matrix</span><span class="cmd-desc">Avvia l'animazione della pioggia digitale Matrix (Ctrl+C per uscire)</span></div>
|
||||
<div><span class="cmd-name">sl</span><span class="cmd-desc">Fai passare la locomotiva a vapore ASCII nel terminale</span></div>
|
||||
<div><span class="cmd-name">sudo <comando></span><span class="cmd-desc">Esegue un comando con privilegi di amministratore</span></div>
|
||||
<div><span class="cmd-name">history</span><span class="cmd-desc">Mostra lo storico dei comandi usati</span></div>
|
||||
<div><span class="cmd-name">clear</span><span class="cmd-desc">Pulisce lo schermo della console</span></div>
|
||||
<div><span class="cmd-name">exit</span><span class="cmd-desc">Chiude la finestra del terminale</span></div>
|
||||
</div>`;
|
||||
this.printOutput(helpHtml);
|
||||
},
|
||||
|
||||
cmdLs() {
|
||||
this.printOutput(`
|
||||
<span class="dir-item">drwxr-xr-x 2 agent otrs 4096 Sep 16 15:00 .</span>
|
||||
<span class="dir-item">drwxr-xr-x 10 agent otrs 4096 Sep 16 15:00 ..</span>
|
||||
<span class="file-item">-rw-r--r-- 1 agent otrs 2450 Sep 16 15:00 tickets.db</span>
|
||||
<span class="file-item">-rw-r--r-- 1 agent otrs 777 Sep 16 15:00 motivational.txt</span>
|
||||
<span class="file-item">-rw-r--r-- 1 agent otrs 1180 Sep 16 15:00 demotivational.txt</span>
|
||||
<span class="file-item">-rw-r--r-- 1 agent otrs 512 Sep 16 15:00 secret.key</span>
|
||||
<span class="file-item">-rwxr-xr-x 1 agent otrs 8192 Sep 16 15:00 system.log</span>
|
||||
`);
|
||||
},
|
||||
|
||||
cmdCat(fileName) {
|
||||
if (!fileName) {
|
||||
this.printOutput('<span class="text-danger">cat: specifica un nome file. Es: cat motivational.txt</span>');
|
||||
return;
|
||||
}
|
||||
const name = fileName.toLowerCase();
|
||||
if (name === 'motivational.txt') {
|
||||
const phrases = App.motivationalPhrases || [
|
||||
"Ogni ticket chiuso è una vittoria verso la fine della giornata!",
|
||||
"La velocità è la nostra virtù con OTRS Turbo!",
|
||||
"Mantieni la calma ed applica le macro."
|
||||
];
|
||||
const random = phrases[Math.floor(Math.random() * phrases.length)];
|
||||
this.printOutput(`<span class="text-info">💡 ${App.escapeHtml(random)}</span>`);
|
||||
} else if (name === 'demotivational.txt') {
|
||||
const phrases = App.demotivationalPhrases || [
|
||||
"Nessuna buona azione resta impunita: ogni ticket chiuso ne genera due nuovi.",
|
||||
"Il weekend si avvicina, ma la coda di ticket non dorme mai.",
|
||||
"Hai davvero controllato se il cavo di rete è collegato?"
|
||||
];
|
||||
const random = phrases[Math.floor(Math.random() * phrases.length)];
|
||||
this.printOutput(`<span class="text-warning">💀 ${App.escapeHtml(random)}</span>`);
|
||||
} else if (name === 'secret.key') {
|
||||
this.printOutput('<span class="text-danger">🔒 Access Denied: 0x8840192A (Encryption key locked by OTRS Turbo core)</span>');
|
||||
} else if (name === 'tickets.db') {
|
||||
this.printOutput('<span class="text-muted">SQLite/PG Binary format header. Usare il comando "tickets" per consultare i dati.</span>');
|
||||
} else if (name === 'system.log') {
|
||||
this.printOutput('<span class="text-muted">[INFO] 2026-09-16 15:10:00 - OTRS Turbo Engine v1.0 running cleanly.\n[INFO] LDAP auto-sync skipped (within 24h window).\n[OK] Database query latency < 4ms.</span>');
|
||||
} else {
|
||||
this.printOutput(`<span class="text-danger">cat: ${App.escapeHtml(fileName)}: No such file or directory</span>`);
|
||||
}
|
||||
},
|
||||
|
||||
cmdHistory() {
|
||||
if (this.history.length === 0) {
|
||||
this.printOutput('<span class="text-muted">Nessun comando nello storico.</span>');
|
||||
return;
|
||||
}
|
||||
const lines = this.history.map((c, i) => ` ${i + 1} ${App.escapeHtml(c)}`).join('\n');
|
||||
this.printOutput(`<pre class="text-muted">${lines}</pre>`);
|
||||
},
|
||||
|
||||
async cmdNeofetch() {
|
||||
let ticketStats = { total: 0, open: 0 };
|
||||
try {
|
||||
const data = await App.api('/api/tickets/stats');
|
||||
if (data) {
|
||||
ticketStats.total = data.total_tickets || data.total || 0;
|
||||
ticketStats.open = data.open_tickets || data.open || 0;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const agent = App.activeAgent || 'gabriele.cimaschi';
|
||||
const html = `
|
||||
<div class="neofetch-container">
|
||||
<pre class="neofetch-logo">
|
||||
██████╗ ████████╗██████╗ ██████╗
|
||||
██╔═══██╗╚══██╔══╝██╔══██╗██╔════╝
|
||||
██║ ██║ ██║ ██████╔╝╚█████╗
|
||||
██║ ██║ ██║ ██╔══██╗ ╚═══██╗
|
||||
╚██████╔╝ ██║ ██║ ██║██████╔╝
|
||||
╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═════╝
|
||||
⚡ T U R B O E N G I N E
|
||||
</pre>
|
||||
<div class="neofetch-info">
|
||||
<div><strong style="color:var(--accent-primary);">${App.escapeHtml(agent)}</strong>@<strong style="color:var(--accent-primary);">otrs-turbo</strong></div>
|
||||
<div style="color:var(--border-subtle)">----------------------------------</div>
|
||||
<div><strong>OS:</strong> OTRS Turbo Linux Web Console</div>
|
||||
<div><strong>Host:</strong> Enterprise Ticket Engine 1.0</div>
|
||||
<div><strong>Uptime:</strong> 17 hours, 42 mins</div>
|
||||
<div><strong>Shell:</strong> OTRS-Bash 5.2</div>
|
||||
<div><strong>Engine:</strong> Express / PG SQL Node.js 18.x</div>
|
||||
<div><strong>Tickets In DB:</strong> ${ticketStats.total} total (${ticketStats.open} aperti)</div>
|
||||
<div><strong>Theme:</strong> ${this.currentTheme}</div>
|
||||
<div class="color-palette" style="margin-top:6px;">
|
||||
<span style="background:#ef4444; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||
<span style="background:#f59e0b; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||
<span style="background:#10b981; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||
<span style="background:#3b82f6; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||
<span style="background:#8b5cf6; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||
<span style="background:#ec4899; display:inline-block; width:12px; height:12px; border-radius:2px; margin-right:3px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
this.printOutput(html);
|
||||
},
|
||||
|
||||
async cmdTickets() {
|
||||
this.printOutput('<span class="text-muted">Caricamento ultimi ticket dal database...</span>');
|
||||
try {
|
||||
const res = await App.api('/api/tickets?page=1&per_page=8');
|
||||
const tickets = res.tickets || [];
|
||||
if (tickets.length === 0) {
|
||||
this.printOutput('<span class="text-warning">Nessun ticket trovato.</span>');
|
||||
return;
|
||||
}
|
||||
|
||||
let rows = `
|
||||
<table class="console-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TN</th>
|
||||
<th>Titolo</th>
|
||||
<th>Stato</th>
|
||||
<th>Coda</th>
|
||||
<th>Cliente</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
tickets.forEach(t => {
|
||||
rows += `
|
||||
<tr>
|
||||
<td class="text-accent">#${App.escapeHtml(t.tn)}</td>
|
||||
<td>${App.escapeHtml((t.title || '').substring(0, 35))}</td>
|
||||
<td><span class="badge-mini">${App.escapeHtml(t.state_name || '')}</span></td>
|
||||
<td>${App.escapeHtml(t.queue_name || '')}</td>
|
||||
<td>${App.escapeHtml(t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—'))}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
rows += `</tbody></table>`;
|
||||
this.printOutput(rows);
|
||||
} catch (err) {
|
||||
this.printOutput(`<span class="text-danger">Errore recupero ticket: ${App.escapeHtml(err.message)}</span>`);
|
||||
}
|
||||
},
|
||||
|
||||
cmdTheme(themeName) {
|
||||
const valid = ['dark', 'green', 'amber', 'cyber'];
|
||||
if (!themeName || !valid.includes(themeName.toLowerCase())) {
|
||||
this.printOutput('<span class="text-warning">Uso: theme <dark|green|amber|cyber></span>');
|
||||
return;
|
||||
}
|
||||
const t = themeName.toLowerCase();
|
||||
const overlay = document.getElementById('linux-console-modal');
|
||||
if (overlay) {
|
||||
overlay.className = `console-modal-overlay theme-${t}`;
|
||||
}
|
||||
this.currentTheme = t;
|
||||
this.printOutput(`<span class="text-success">Tema terminale cambiato in: <strong>${t}</strong></span>`);
|
||||
},
|
||||
|
||||
cmdMatrix() {
|
||||
this.printOutput('<span class="text-success">Inizializzazione matrice in corso... premi Esc o Ctrl+C per fermare.</span>');
|
||||
this.hideInputRow();
|
||||
const outputDiv = document.getElementById('console-output');
|
||||
const matrixBox = document.createElement('pre');
|
||||
matrixBox.className = 'matrix-stream';
|
||||
outputDiv.appendChild(matrixBox);
|
||||
|
||||
const chars = '0123456789ABCDEFヲアウエオカキケコサシスセソタツテナニヌネハヒホマミムメモヤユラリワ';
|
||||
const cols = 60;
|
||||
|
||||
this.activeAnimation = setInterval(() => {
|
||||
let line = '';
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const char = chars[Math.floor(Math.random() * chars.length)];
|
||||
line += char + ' ';
|
||||
}
|
||||
matrixBox.textContent += line + '\n';
|
||||
this.scrollToBottom();
|
||||
if (matrixBox.textContent.length > 3000) {
|
||||
matrixBox.textContent = matrixBox.textContent.substring(1500);
|
||||
}
|
||||
}, 80);
|
||||
},
|
||||
|
||||
cmdSl() {
|
||||
this.printOutput('<span class="text-warning">Choo Choo! Locomotiva in transito...</span>');
|
||||
this.hideInputRow();
|
||||
const outputDiv = document.getElementById('console-output');
|
||||
const trainBox = document.createElement('pre');
|
||||
trainBox.className = 'sl-train-box';
|
||||
outputDiv.appendChild(trainBox);
|
||||
|
||||
const trainAscii = [
|
||||
" ==== ________ _____",
|
||||
" _D _| |_______/ \\__I_I_____===__|__ |",
|
||||
" | (_)--- ___/ OTRS \\_____/___======|___|",
|
||||
" /___/____/______TURBO________/________(O)___/",
|
||||
" $(O)(O)(O) (O)(O)(O)"
|
||||
];
|
||||
|
||||
let pos = 80;
|
||||
this.activeAnimation = setInterval(() => {
|
||||
pos -= 3;
|
||||
if (pos < -60) {
|
||||
this.stopAnimation();
|
||||
this.showInputRow();
|
||||
trainBox.remove();
|
||||
this.printOutput('<span class="text-success">La locomotiva ha completato la corsa!</span>');
|
||||
return;
|
||||
}
|
||||
const spaces = ' '.repeat(Math.max(0, pos));
|
||||
trainBox.textContent = trainAscii.map(l => spaces + l).join('\n');
|
||||
this.scrollToBottom();
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
window.ConsoleView = ConsoleView;
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
const RicercaSpudorataView = {
|
||||
searchTerm: '',
|
||||
isRegex: false,
|
||||
cachedTickets: null,
|
||||
currentPage: 1,
|
||||
perPage: 50,
|
||||
@@ -29,6 +30,10 @@ const RicercaSpudorataView = {
|
||||
|
||||
const urlParams = new URLSearchParams((window.location.hash.split('?')[1]) || '');
|
||||
const qParam = urlParams.get('q');
|
||||
const regexParam = urlParams.get('regex');
|
||||
if (regexParam !== null) {
|
||||
this.isRegex = regexParam === '1' || regexParam === 'true';
|
||||
}
|
||||
|
||||
if (qParam && qParam !== this.searchTerm) {
|
||||
this.renderContainer(container);
|
||||
@@ -62,12 +67,54 @@ const RicercaSpudorataView = {
|
||||
renderContainer(container) {
|
||||
container.innerHTML = `
|
||||
<!-- 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; 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>
|
||||
<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>
|
||||
<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);">
|
||||
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>
|
||||
@@ -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="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>
|
||||
</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;">
|
||||
<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>
|
||||
@@ -96,12 +151,10 @@ const RicercaSpudorataView = {
|
||||
</div>
|
||||
|
||||
<!-- Batch Actions Bar -->
|
||||
<div class="batch-bar" id="batch-bar">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||
<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" style="margin-left: 8px;">Copia numeri</button>
|
||||
</div>
|
||||
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
|
||||
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Stato</span>
|
||||
<select class="filter-select" id="batch-state">
|
||||
@@ -138,11 +191,40 @@ const RicercaSpudorataView = {
|
||||
</select>
|
||||
</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-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-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>
|
||||
<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>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</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;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<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>
|
||||
|
||||
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
|
||||
@@ -167,6 +249,15 @@ const RicercaSpudorataView = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRegex) {
|
||||
try {
|
||||
new RegExp(query);
|
||||
} catch (e) {
|
||||
Toast.error('Espressione regolare non valida: ' + e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.searchTerm = query;
|
||||
this.isFetching = true;
|
||||
|
||||
@@ -175,13 +266,14 @@ const RicercaSpudorataView = {
|
||||
resultsArea.innerHTML = `
|
||||
<div class="loading-screen" style="padding: 60px 0;">
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
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.otrsBaseUrl = data.otrsBaseUrl || '';
|
||||
this.hasSearched = true;
|
||||
@@ -189,7 +281,7 @@ const RicercaSpudorataView = {
|
||||
this.selectedIds.clear();
|
||||
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.bindEvents();
|
||||
this.renderLocalResults();
|
||||
@@ -280,7 +372,7 @@ const RicercaSpudorataView = {
|
||||
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; vertical-align:middle; line-height:16px;">O</span>
|
||||
</a>
|
||||
` : ''}
|
||||
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
||||
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title, true); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
||||
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
||||
</span>
|
||||
</td>
|
||||
@@ -334,8 +426,22 @@ const RicercaSpudorataView = {
|
||||
const s = Filters.state;
|
||||
|
||||
if (s.sub_search) {
|
||||
const q = String(s.sub_search).toLowerCase().trim();
|
||||
if (q) {
|
||||
const rawQ = String(s.sub_search).trim();
|
||||
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 => {
|
||||
const fields = [
|
||||
t.tn,
|
||||
@@ -352,7 +458,10 @@ const RicercaSpudorataView = {
|
||||
t.customer_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) {
|
||||
resetCacheBtn.addEventListener('click', () => {
|
||||
this.invalidateCache();
|
||||
|
||||
@@ -79,6 +79,20 @@ const ShortcutsView = {
|
||||
context: 'Globale',
|
||||
defaultCombo: { ctrl: true, alt: false, shift: true, key: 'Backspace', enabled: true }
|
||||
},
|
||||
{
|
||||
id: 'open_terminal_console',
|
||||
name: 'Apri Terminale Linux',
|
||||
description: 'Apre la finestra di simulazione del terminale Linux in overlay',
|
||||
context: 'Globale',
|
||||
defaultCombo: { ctrl: true, alt: true, shift: false, key: 't', enabled: true }
|
||||
},
|
||||
{
|
||||
id: 'add_ticket_tab',
|
||||
name: 'Aggiungi Ticket in Scheda',
|
||||
description: 'Aggiunge il ticket visualizzato (o selezionato nell\'elenco) alle schede aperte in alto',
|
||||
context: 'Globale / Dettaglio / Elenco',
|
||||
defaultCombo: { ctrl: true, alt: true, shift: false, key: 's', enabled: true }
|
||||
},
|
||||
{
|
||||
id: 'create_ticket_submit',
|
||||
name: 'Invia Nuovo Ticket',
|
||||
|
||||
@@ -128,7 +128,7 @@ const TicketCreateView = {
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Titolo <span class="required">*</span></label>
|
||||
<input type="text" class="form-input" id="create-title" placeholder="Descrizione breve del problema" autofocus />
|
||||
<input type="text" class="form-input" id="create-title" placeholder="Descrizione breve del problema" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="position:relative;">
|
||||
@@ -615,6 +615,11 @@ const TicketCreateView = {
|
||||
}
|
||||
});
|
||||
|
||||
// Set focus on State dropdown (#create-state) on page load
|
||||
setTimeout(() => {
|
||||
document.getElementById('create-state')?.focus();
|
||||
}, 50);
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
const title = document.getElementById('create-title').value.trim();
|
||||
const queue_id = queueIdInput.value;
|
||||
|
||||
+108
-24
@@ -12,16 +12,20 @@ const TicketDetailView = {
|
||||
if (!this.ticketId) return;
|
||||
const body = this.noteQuill ? this.noteQuill.root.innerHTML.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 : '';
|
||||
|
||||
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, {
|
||||
type: 'note',
|
||||
body,
|
||||
subject,
|
||||
time_unit,
|
||||
ticket_state_id,
|
||||
note_create_time,
|
||||
attachments: [...this.noteAttachments]
|
||||
});
|
||||
} else {
|
||||
@@ -3453,7 +3457,7 @@ const TicketDetailView = {
|
||||
<span class="copy-full-ticket-title" data-full="${ticket.tn} - ${App.escapeHtml(ticket.title || '(senza titolo)')}" style="cursor: pointer;" title="Clicca per copiare: ${ticket.tn} - ${App.escapeHtml(ticket.title || '')}">
|
||||
${App.escapeHtml(ticket.title || '(senza titolo)')}
|
||||
</span>
|
||||
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
|
||||
<button class="open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.openTab(${ticket.id}, '${ticket.tn}', this.dataset.title, true); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-left:6px; cursor:pointer; line-height:16px;">+</button>
|
||||
<button class="assign-open-tab-btn" data-id="${ticket.id}" data-tn="${ticket.tn}" data-title="${App.escapeHtml(ticket.title || '(senza titolo)')}" onclick="App.assignAndOpenTab(${ticket.id}, '${ticket.tn}', this.dataset.title); event.stopPropagation();" title="Assegna a me ed apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 8px; background:var(--accent-primary); color:#fff; border:none; border-radius:var(--radius-full); font-size:11px; font-weight:600; font-family:inherit; margin-left:6px; cursor:pointer; gap:3px;">
|
||||
👤+ Assegna & Scheda
|
||||
</button>
|
||||
@@ -3562,6 +3566,10 @@ const TicketDetailView = {
|
||||
<input type="hidden" id="qe-customer-id" data-field="customer_id" value="${ticket.customer_id || ''}" />
|
||||
<div id="qe-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%;"></div>
|
||||
</div>
|
||||
<div class="quick-edit-field">
|
||||
<label class="quick-edit-label">Consuntivazione Tempo (min)</label>
|
||||
<input type="number" step="any" min="0" class="form-input" id="note-time-units" placeholder="minuti" style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Integrated Note & Article Editor inside Modifica Rapida -->
|
||||
@@ -3583,7 +3591,11 @@ const TicketDetailView = {
|
||||
<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>
|
||||
<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;" />
|
||||
<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>
|
||||
@@ -3591,9 +3603,6 @@ const TicketDetailView = {
|
||||
<label for="note-create-time" style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Data/Ora:</label>
|
||||
<input type="datetime-local" class="note-subject-input" id="note-create-time" title="Data/ora nota (opzionale per retrodatazione)" style="width:185px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.82rem;" />
|
||||
|
||||
<label for="note-time-units" style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-left:4px;">Tempo (min):</label>
|
||||
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="minuti" style="width:90px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.85rem;" />
|
||||
|
||||
<button class="btn btn-ghost btn-sm" id="btn-open-email-compose" style="height:32px; display:flex; align-items:center; gap:var(--space-xs); border-color:var(--accent-secondary); color:var(--accent-secondary);"
|
||||
data-ticket-id="${ticket.id}" data-ticket-tn="${ticket.tn}" data-ticket-title="${App.escapeHtml(ticket.title)}" data-customer-email="${App.escapeHtml(ticket.customer_email || '')}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
@@ -3990,9 +3999,15 @@ const TicketDetailView = {
|
||||
if (document.getElementById('note-time-units')) {
|
||||
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) {
|
||||
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.updateNoteAttachmentList();
|
||||
}
|
||||
@@ -4023,6 +4038,7 @@ const TicketDetailView = {
|
||||
const customerIdInput = document.getElementById('qe-customer-id');
|
||||
const noteSubjectInput = document.getElementById('note-subject');
|
||||
const noteTimeUnitsInput = document.getElementById('note-time-units');
|
||||
const noteTimeUnitsBottomInput = document.getElementById('note-time-units-bottom');
|
||||
const noteCreateTimeInput = document.getElementById('note-create-time');
|
||||
|
||||
const checkChanges = () => {
|
||||
@@ -4046,7 +4062,8 @@ const TicketDetailView = {
|
||||
const noteBodyText = (this.noteQuill ? this.noteQuill.root.innerText : '').trim();
|
||||
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 (noteCreateTimeInput && noteCreateTimeInput.value) hasChanges = true;
|
||||
@@ -4064,10 +4081,38 @@ const TicketDetailView = {
|
||||
noteSubjectInput.addEventListener('input', checkChanges);
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
this.noteQuill.on('text-change', checkChanges);
|
||||
@@ -4092,6 +4137,7 @@ const TicketDetailView = {
|
||||
if (noteSubjectInput) noteSubjectInput.value = '';
|
||||
if (this.noteQuill) this.noteQuill.root.innerHTML = '';
|
||||
if (noteTimeUnitsInput) noteTimeUnitsInput.value = '';
|
||||
if (noteTimeUnitsBottomInput) noteTimeUnitsBottomInput.value = '';
|
||||
if (noteCreateTimeInput) noteCreateTimeInput.value = '';
|
||||
this.noteAttachments = [];
|
||||
this.updateNoteAttachmentList();
|
||||
@@ -4176,36 +4222,53 @@ const TicketDetailView = {
|
||||
customerSearchInput.addEventListener('input', () => {
|
||||
clearTimeout(customerDebounce);
|
||||
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 (customerIdInput) customerIdInput.value = '';
|
||||
} else {
|
||||
if (customerUserIdInput) customerUserIdInput.value = this.originalValues['customer_user_id'] || '';
|
||||
if (customerIdInput) customerIdInput.value = this.originalValues['customer_id'] || '';
|
||||
}
|
||||
checkChanges();
|
||||
|
||||
customerDebounce = setTimeout(async () => {
|
||||
try {
|
||||
const customers = await App.api(`/api/customers/search?q=${encodeURIComponent(q)}`);
|
||||
if (!customers || customers.length === 0) {
|
||||
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||||
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.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
customerSuggestionsDiv.innerHTML = customers.map(c => `
|
||||
<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;">
|
||||
<strong>${App.escapeHtml(c.first_name ? `${c.first_name} ${c.last_name}` : c.customer_user_id)}</strong>
|
||||
${c.email ? `<span style="color:var(--text-muted); margin-left:6px;">(${App.escapeHtml(c.email)})</span>` : ''}
|
||||
customerSuggestionsDiv.innerHTML = users.map(u => {
|
||||
const fullName = (u.first_name || u.last_name) ? `${u.first_name || ''} ${u.last_name || ''}`.trim() : (u.login || '');
|
||||
return `
|
||||
<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>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
customerSuggestionsDiv.style.display = 'block';
|
||||
|
||||
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||
if (item.dataset.userId) {
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
const selectCustomerItem = (item) => {
|
||||
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;
|
||||
customerSuggestionsDiv.style.display = 'none';
|
||||
checkChanges();
|
||||
};
|
||||
|
||||
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||
if (item.dataset.login) {
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
selectCustomerItem(item);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4215,6 +4278,18 @@ const TicketDetailView = {
|
||||
}, 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.dispatchEvent(new Event('input'));
|
||||
});
|
||||
@@ -4245,9 +4320,18 @@ const TicketDetailView = {
|
||||
}
|
||||
|
||||
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
|
||||
if (customerSearch && customerUserIdInput && !customerUserIdInput.value) {
|
||||
customerUserIdInput.value = customerSearch;
|
||||
if (customerIdInput) customerIdInput.value = customerSearch;
|
||||
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 (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
|
||||
|
||||
@@ -250,12 +250,11 @@ const TicketListView = {
|
||||
|
||||
container.innerHTML = `
|
||||
<!-- Batch Actions Bar -->
|
||||
<div class="batch-bar" id="batch-bar">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||
<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" style="margin-left: 8px;">Copia numeri</button>
|
||||
</div>
|
||||
<!-- Batch Actions 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;">
|
||||
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Stato</span>
|
||||
<select class="filter-select" id="batch-state">
|
||||
@@ -292,11 +291,40 @@ const TicketListView = {
|
||||
</select>
|
||||
</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-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-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>
|
||||
<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>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</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;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<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>
|
||||
|
||||
${Filters.renderBar(App.lookups)}
|
||||
@@ -333,7 +361,7 @@ const TicketListView = {
|
||||
<span style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:#1070ca; color:#fff; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; vertical-align:middle; line-height:16px;">O</span>
|
||||
</a>
|
||||
` : ''}
|
||||
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
||||
<button class="open-tab-btn" data-id="${t.id}" data-tn="${t.tn}" data-title="${App.escapeHtml(t.title || '')}" onclick="App.openTab(${t.id}, '${t.tn}', this.dataset.title, true); event.stopPropagation();" title="Apri in scheda" style="display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px; background:var(--accent-primary); color:#fff; border:none; border-radius:50%; font-size:10px; font-weight:800; font-family:sans-serif; margin-right:6px; cursor:pointer; line-height:16px;">+</button>
|
||||
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -12,6 +12,39 @@ const TimeAccountingView = {
|
||||
viewMode: 'list', // 'list' | 'calendar'
|
||||
showAllTime: false,
|
||||
showNonWorkingDays: localStorage.getItem('otrs_ta_show_non_working_days') !== 'false',
|
||||
groupByTicket: localStorage.getItem('otrs_ta_group_by_ticket') !== 'false',
|
||||
|
||||
groupArticles(articles) {
|
||||
if (!articles || articles.length === 0) return [];
|
||||
if (!this.groupByTicket) {
|
||||
return articles.map(a => ({ type: 'single', article: a }));
|
||||
}
|
||||
|
||||
const groups = [];
|
||||
let currentGroup = null;
|
||||
|
||||
for (const a of articles) {
|
||||
const ticketId = a.ticket_id;
|
||||
if (currentGroup && currentGroup.ticket_id === ticketId) {
|
||||
currentGroup.articles.push(a);
|
||||
} else {
|
||||
if (currentGroup) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup = {
|
||||
type: 'group',
|
||||
ticket_id: ticketId,
|
||||
ticket_tn: a.ticket_tn,
|
||||
ticket_title: a.ticket_title,
|
||||
articles: [a]
|
||||
};
|
||||
}
|
||||
}
|
||||
if (currentGroup) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
|
||||
formatLocalISO(d) {
|
||||
if (!d) return '';
|
||||
@@ -174,6 +207,14 @@ const TimeAccountingView = {
|
||||
|
||||
<div style="display:flex; gap:var(--space-xs); align-self:flex-end;">
|
||||
<button id="ta-btn-today" class="btn btn-ghost btn-sm" style="height:36px;">Oggi</button>
|
||||
<button id="ta-btn-toggle-groupby" class="btn btn-ghost btn-sm" style="height:36px; display:flex; align-items:center; gap:6px; background:${this.groupByTicket ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; color:${this.groupByTicket ? '#ffffff' : 'var(--text-secondary)'}; border:1px solid var(--border-subtle);" title="Raggruppa le note adiacenti appartenenti allo stesso ticket">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px; height:15px;">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<span>${this.groupByTicket ? 'Raggruppa per Ticket (Attivo)' : 'Raggruppa per Ticket'}</span>
|
||||
</button>
|
||||
<button id="ta-btn-toggle-nonwork" class="btn btn-ghost btn-sm" style="height:36px; display:flex; align-items:center; gap:6px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px; height:15px;">
|
||||
${this.showNonWorkingDays
|
||||
@@ -193,7 +234,7 @@ const TimeAccountingView = {
|
||||
<div style="display:flex; gap:var(--space-md); padding:var(--space-md) var(--space-xl); align-items:flex-start; min-height:calc(100vh - 280px);">
|
||||
|
||||
<!-- Left Side: List of Articles -->
|
||||
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-sm);">
|
||||
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-md);">
|
||||
${articles.length === 0 ? `
|
||||
<div class="empty-state" style="padding:var(--space-2xl); text-align:center; color:var(--text-muted); background:var(--bg-card); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px; height:48px; margin:0 auto var(--space-md); display:block; opacity:0.4;">
|
||||
@@ -201,7 +242,7 @@ const TimeAccountingView = {
|
||||
</svg>
|
||||
<p style="font-size:0.9rem; font-weight:500;">Nessuna nota o email trovata in questa data per l'agente selezionato.</p>
|
||||
</div>
|
||||
` : articles.map(a => this.buildArticleCard(a)).join('')}
|
||||
` : this.groupArticles(articles).map(g => g.type === 'single' ? this.buildArticleCard(g.article) : this.buildGroupCard(g)).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Details & Actions Panel -->
|
||||
@@ -292,8 +333,21 @@ const TimeAccountingView = {
|
||||
borderStyle = '1px solid var(--border-subtle)';
|
||||
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 {
|
||||
// Working days (configured in WORK_DAYS)
|
||||
// Working days con consuntivazione (> 0 min)
|
||||
if (isTargetReached) {
|
||||
// Verdino (green)
|
||||
if (isSelected) {
|
||||
@@ -437,6 +491,18 @@ const TimeAccountingView = {
|
||||
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||
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 {
|
||||
if (isTargetReached) {
|
||||
if (isSelected) {
|
||||
@@ -574,6 +640,40 @@ const TimeAccountingView = {
|
||||
`;
|
||||
},
|
||||
|
||||
buildGroupCard(group) {
|
||||
const totalGroupMins = Math.round(group.articles.reduce((acc, a) => acc + (parseFloat(a.time_unit) || 0), 0));
|
||||
const formattedGroupTime = this.formatPillTime(totalGroupMins);
|
||||
const hasSelectedArticle = group.articles.some(a => String(a.article_id) === String(this.selectedArticleId));
|
||||
|
||||
return `
|
||||
<div class="card ta-group-card" style="border:1px solid ${hasSelectedArticle ? 'var(--accent-primary)' : 'var(--border-subtle)'}; background:var(--bg-card); border-radius:var(--radius-md); overflow:hidden;">
|
||||
<!-- Header del Gruppo Ticket -->
|
||||
<div style="padding:10px 14px; background:var(--bg-secondary); border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; gap:10px;">
|
||||
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||
<span style="font-size:0.9rem;">🎫</span>
|
||||
<a href="#/tickets/${group.ticket_id}" class="ticket-tn-link" onclick="event.stopPropagation();" style="font-weight:700; font-size:0.9rem;">#${App.escapeHtml(group.ticket_tn || group.ticket_id)}</a>
|
||||
<span style="font-size:0.85rem; font-weight:600; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${App.escapeHtml(group.ticket_title || '')}">
|
||||
${App.escapeHtml(group.ticket_title || '(senza titolo)')}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
|
||||
<span class="badge" style="background:var(--bg-tertiary); color:var(--text-secondary); font-size:0.75rem; font-weight:600;">
|
||||
${group.articles.length} ${group.articles.length === 1 ? 'nota' : 'note'}
|
||||
</span>
|
||||
<span class="badge" style="background:${totalGroupMins > 0 ? 'rgba(34,197,94,0.18)' : 'var(--bg-tertiary)'}; color:${totalGroupMins > 0 ? '#15803d' : 'var(--text-muted)'}; font-weight:700; font-size:0.78rem;">
|
||||
⏱️ Totale: ${formattedGroupTime} (${totalGroupMins} m)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note del Gruppo -->
|
||||
<div style="display:flex; flex-direction:column; gap:6px; padding:10px;">
|
||||
${group.articles.map(a => this.buildArticleCard(a)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
buildDetailPanel(a) {
|
||||
const timeMins = parseFloat(a.time_unit || 0);
|
||||
|
||||
@@ -687,6 +787,13 @@ const TimeAccountingView = {
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Toggle group by ticket button
|
||||
document.getElementById('ta-btn-toggle-groupby')?.addEventListener('click', () => {
|
||||
this.groupByTicket = !this.groupByTicket;
|
||||
localStorage.setItem('otrs_ta_group_by_ticket', this.groupByTicket);
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Day pill clicks (also handles calendar cell clicks)
|
||||
document.querySelectorAll('.ta-day-pill[data-date]').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
|
||||
@@ -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)
|
||||
router.get('/customer-users/search', async (req, res) => {
|
||||
const { q = '', customer_company_id } = req.query;
|
||||
|
||||
+105
-9
@@ -212,6 +212,30 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
const searchTerm = search || sub_search;
|
||||
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(`(
|
||||
t.title ILIKE $${paramIdx} OR
|
||||
t.tn ILIKE $${paramIdx} OR
|
||||
@@ -227,6 +251,7 @@ router.get('/', async (req, res) => {
|
||||
params.push(`%${searchTerm}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0
|
||||
? 'WHERE ' + conditions.join(' AND ')
|
||||
@@ -313,12 +338,37 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/tickets/stats — Ticket statistics overview
|
||||
// ============================================================
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const totalRes = await pool.query('SELECT COUNT(*) as total FROM ticket');
|
||||
const openRes = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM ticket t
|
||||
JOIN ticket_state ts ON t.ticket_state_id = ts.id
|
||||
JOIN ticket_state_type tst ON ts.type_id = tst.id
|
||||
WHERE tst.name IN ('new', 'open', 'pending auto', 'pending reminder')`
|
||||
);
|
||||
res.json({
|
||||
total_tickets: parseInt(totalRes.rows[0]?.total || 0),
|
||||
open_tickets: parseInt(openRes.rows[0]?.total || 0),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching ticket stats:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/tickets/:id — Single ticket detail
|
||||
// ============================================================
|
||||
router.get('/:id', async (req, res) => {
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'ID ticket non valido' });
|
||||
}
|
||||
|
||||
const ticketResult = await pool.query(
|
||||
`SELECT
|
||||
@@ -362,7 +412,11 @@ router.get('/:id', async (req, res) => {
|
||||
// 1. Try to read from local SQLite cache in internal.db
|
||||
let cachedUser = null;
|
||||
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) {
|
||||
console.warn('[LDAP Cache Detail] SQLite cache read failed:', dbErr.message);
|
||||
}
|
||||
@@ -373,8 +427,20 @@ router.get('/:id', async (req, res) => {
|
||||
ticket.customer_email = cachedUser.email || '';
|
||||
ticket.customer_phone = cachedUser.phone || '';
|
||||
} else {
|
||||
// 2. Fallback to live API fetch
|
||||
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: ticket.customer_user_id });
|
||||
// 2. Fallback to live API fetch (only if login doesn't contain spaces)
|
||||
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;
|
||||
if (getData && getData.CustomerUser) {
|
||||
const u = getData.CustomerUser;
|
||||
@@ -407,6 +473,7 @@ router.get('/:id', async (req, res) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.warn(`Failed to fetch LDAP customer details for detail view:`, apiErr.message);
|
||||
}
|
||||
@@ -915,9 +982,19 @@ router.patch('/:id', async (req, res) => {
|
||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||
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_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 (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
|
||||
@@ -1648,9 +1725,19 @@ router.patch('/batch/update', async (req, res) => {
|
||||
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_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_id !== undefined) ticketFields.CustomerID = updates.customer_id;
|
||||
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_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;
|
||||
|
||||
// Auto sblocco check
|
||||
if (updates.ticket_state_id) {
|
||||
@@ -1890,6 +1977,9 @@ router.delete('/articles/:articleId', async (req, res) => {
|
||||
router.get('/attachments/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return res.status(404).send('Allegato non trovato.');
|
||||
}
|
||||
const result = await pool.query(
|
||||
`SELECT filename, content_type, content
|
||||
FROM article_data_mime_attachment
|
||||
@@ -1962,6 +2052,9 @@ router.get('/attachments/:id', async (req, res) => {
|
||||
router.put('/attachments/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return res.status(404).json({ error: 'Allegato non trovato' });
|
||||
}
|
||||
const { content } = req.body;
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'Contenuto non valido' });
|
||||
@@ -1991,6 +2084,9 @@ router.put('/attachments/:id', async (req, res) => {
|
||||
router.post('/attachments/:id/open', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!/^\d+$/.test(id)) {
|
||||
return res.status(404).json({ error: 'Allegato non trovato' });
|
||||
}
|
||||
const result = await pool.query(
|
||||
`SELECT filename, content_type, content FROM article_data_mime_attachment WHERE id = $1`,
|
||||
[id]
|
||||
|
||||
Reference in new issue
Block a user