style: riviste parti dell'interfaccia dei ticket. feat: aggiunta possiblità di modificare il tempo inserito nelle note ed aggiunta voce per tempo nel menu azioni rapide
This commit is contained in:
@@ -903,6 +903,36 @@ body {
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Queue Custom Tooltip */
|
||||||
|
.ticket-table td.queue-cell {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
.queue-tooltip {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 100%;
|
||||||
|
transform: translate(-50%, -6px);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 1000;
|
||||||
|
transition: opacity 0.05s ease, visibility 0.05s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.queue-cell:hover .queue-tooltip {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.ticket-table td {
|
.ticket-table td {
|
||||||
padding: 10px var(--space-md);
|
padding: 10px var(--space-md);
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
@@ -1067,6 +1097,12 @@ body {
|
|||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-multiselect-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group {
|
.filter-group {
|
||||||
|
|||||||
+176
-21
@@ -3,21 +3,50 @@
|
|||||||
* Manages ticket list filter state and renders filter dropdowns.
|
* Manages ticket list filter state and renders filter dropdowns.
|
||||||
*/
|
*/
|
||||||
const Filters = {
|
const Filters = {
|
||||||
state: {
|
currentMode: 'general', // 'general' or 'my'
|
||||||
queue_id: '',
|
|
||||||
state_id: '',
|
allStates: {
|
||||||
priority_id: '',
|
general: {
|
||||||
user_id: '',
|
queue_id: '',
|
||||||
date_from: '',
|
state_id: '',
|
||||||
date_to: '',
|
priority_id: '',
|
||||||
|
user_id: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: '',
|
||||||
|
},
|
||||||
|
my: {
|
||||||
|
queue_id: '',
|
||||||
|
state_id: '',
|
||||||
|
priority_id: '',
|
||||||
|
user_id: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dynamic state getter based on active mode
|
||||||
|
get state() {
|
||||||
|
return this.allStates[this.currentMode];
|
||||||
|
},
|
||||||
|
|
||||||
|
set state(val) {
|
||||||
|
this.allStates[this.currentMode] = val;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Load saved filters from localStorage */
|
/** Load saved filters from localStorage */
|
||||||
load() {
|
load() {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('otrs_turbo_filters');
|
const savedGeneral = localStorage.getItem('otrs_turbo_filters_general');
|
||||||
if (saved) {
|
if (savedGeneral) {
|
||||||
Object.assign(this.state, JSON.parse(saved));
|
Object.assign(this.allStates.general, JSON.parse(savedGeneral));
|
||||||
|
}
|
||||||
|
const savedMy = localStorage.getItem('otrs_turbo_filters_my');
|
||||||
|
if (savedMy) {
|
||||||
|
Object.assign(this.allStates.my, JSON.parse(savedMy));
|
||||||
|
} else {
|
||||||
|
// Default to active agent if not saved yet
|
||||||
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
this.allStates.my.user_id = activeAgentId;
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
},
|
},
|
||||||
@@ -25,13 +54,17 @@ const Filters = {
|
|||||||
/** Save filters to localStorage */
|
/** Save filters to localStorage */
|
||||||
save() {
|
save() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('otrs_turbo_filters', JSON.stringify(this.state));
|
localStorage.setItem(`otrs_turbo_filters_${this.currentMode}`, JSON.stringify(this.state));
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset all filters */
|
/** Reset all filters */
|
||||||
reset() {
|
reset() {
|
||||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
|
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
|
||||||
|
if (this.currentMode === 'my') {
|
||||||
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
|
this.state.user_id = activeAgentId;
|
||||||
|
}
|
||||||
this.save();
|
this.save();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -44,6 +77,36 @@ const Filters = {
|
|||||||
return params;
|
return params;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Helper to compute trigger button label text */
|
||||||
|
getStateMultiselectLabel(lookups) {
|
||||||
|
const selectedList = Array.isArray(this.state.state_id)
|
||||||
|
? this.state.state_id
|
||||||
|
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
|
||||||
|
|
||||||
|
if (selectedList.length === 0) {
|
||||||
|
return 'Tutti';
|
||||||
|
}
|
||||||
|
const selectedNames = (lookups.states || [])
|
||||||
|
.filter(s => selectedList.includes(String(s.id)))
|
||||||
|
.map(s => s.name);
|
||||||
|
|
||||||
|
if (selectedNames.length === (lookups.states || []).length) {
|
||||||
|
return 'Tutti';
|
||||||
|
} else if (selectedNames.length <= 2) {
|
||||||
|
return selectedNames.join(', ');
|
||||||
|
} else {
|
||||||
|
return `${selectedNames.length} selezionati`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update label DOM element dynamically */
|
||||||
|
updateStateMultiselectLabel(lookups) {
|
||||||
|
const labelEl = document.getElementById('state-multiselect-label');
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = this.getStateMultiselectLabel(lookups);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render filter bar HTML.
|
* Render filter bar HTML.
|
||||||
* @param {Object} lookups - { queues, states, priorities, users }
|
* @param {Object} lookups - { queues, states, priorities, users }
|
||||||
@@ -59,14 +122,36 @@ const Filters = {
|
|||||||
}).join('');
|
}).join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentLabel = this.getStateMultiselectLabel(lookups);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="filters-bar" id="filters-bar">
|
<div class="filters-bar" id="filters-bar">
|
||||||
<div class="filter-group">
|
<div class="filter-group" style="position:relative;">
|
||||||
<span class="filter-label">Stato</span>
|
<span class="filter-label">Stato</span>
|
||||||
<select class="filter-select" data-filter="state_id" id="filter-state">
|
<div class="multiselect-dropdown" id="state-multiselect-dropdown" style="min-width: 140px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 4px var(--space-sm); font-size: 0.85rem; cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;">
|
||||||
<option value="">Tutti</option>
|
<span class="multiselect-label" id="state-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:130px;">${App.escapeHtml(currentLabel)}</span>
|
||||||
${makeOptions(lookups.states || [], 'id', 'name', this.state.state_id)}
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
</select>
|
</div>
|
||||||
|
<div class="multiselect-popover" id="state-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 220px; background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); box-shadow:var(--shadow-md); z-index:1002; padding:var(--space-xs); margin-top:4px;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:4px; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
|
${(lookups.states || []).map(s => {
|
||||||
|
const selectedList = Array.isArray(this.state.state_id)
|
||||||
|
? this.state.state_id
|
||||||
|
: (typeof this.state.state_id === 'string' && this.state.state_id ? this.state.state_id.split(',') : []);
|
||||||
|
const isSelected = selectedList.includes(String(s.id));
|
||||||
|
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||||
|
return `
|
||||||
|
<div class="state-multiselect-item ${isSelected ? 'active' : ''}" data-value="${s.id}" style="padding: 6px var(--space-sm); border-radius: var(--radius-sm); font-size: 0.85rem; cursor: pointer; user-select: none; transition: background 0.1s ease; ${activeStyle}">
|
||||||
|
${App.escapeHtml(s.name)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:8px; padding-top:6px; font-size:0.75rem;">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" id="state-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-xs" id="state-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Coda</span>
|
<span class="filter-label">Coda</span>
|
||||||
@@ -109,11 +194,7 @@ const Filters = {
|
|||||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||||
const selects = document.querySelectorAll('.filter-select[data-filter]');
|
const selects = document.querySelectorAll('.filter-select[data-filter]');
|
||||||
selects.forEach(sel => {
|
selects.forEach(sel => {
|
||||||
if (isMyTickets && sel.dataset.filter === 'user_id') {
|
sel.disabled = false;
|
||||||
sel.disabled = true;
|
|
||||||
} else {
|
|
||||||
sel.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
sel.addEventListener('change', (e) => {
|
sel.addEventListener('change', (e) => {
|
||||||
this.state[e.target.dataset.filter] = e.target.value;
|
this.state[e.target.dataset.filter] = e.target.value;
|
||||||
@@ -122,6 +203,68 @@ const Filters = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Multiselect dropdown toggle event
|
||||||
|
const dropdown = document.getElementById('state-multiselect-dropdown');
|
||||||
|
const popover = document.getElementById('state-multiselect-popover');
|
||||||
|
const okBtn = document.getElementById('state-multiselect-ok');
|
||||||
|
const clearBtn = document.getElementById('state-multiselect-clear');
|
||||||
|
|
||||||
|
if (dropdown && popover) {
|
||||||
|
dropdown.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isOpen = popover.style.display === 'block';
|
||||||
|
popover.style.display = isOpen ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
popover.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind item clicks
|
||||||
|
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isActive = item.classList.toggle('active');
|
||||||
|
if (isActive) {
|
||||||
|
item.style.background = 'var(--accent-primary)';
|
||||||
|
item.style.color = '#fff';
|
||||||
|
} else {
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.addEventListener('click', () => {
|
||||||
|
const activeItems = popover.querySelectorAll('.state-multiselect-item.active');
|
||||||
|
const ids = Array.from(activeItems).map(item => item.dataset.value);
|
||||||
|
this.state.state_id = ids.join(',');
|
||||||
|
this.save();
|
||||||
|
this.updateStateMultiselectLabel(App.lookups);
|
||||||
|
popover.style.display = 'none';
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
popover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
});
|
||||||
|
this.state.state_id = '';
|
||||||
|
this.save();
|
||||||
|
this.updateStateMultiselectLabel(App.lookups);
|
||||||
|
popover.style.display = 'none';
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const resetBtn = document.getElementById('filter-reset');
|
const resetBtn = document.getElementById('filter-reset');
|
||||||
if (resetBtn) {
|
if (resetBtn) {
|
||||||
resetBtn.addEventListener('click', () => {
|
resetBtn.addEventListener('click', () => {
|
||||||
@@ -138,6 +281,18 @@ const Filters = {
|
|||||||
s.value = '';
|
s.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also clear multiselect items and label
|
||||||
|
const statePopover = document.getElementById('state-multiselect-popover');
|
||||||
|
if (statePopover) {
|
||||||
|
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.updateStateMultiselectLabel(App.lookups);
|
||||||
|
|
||||||
if (onFilterChange) onFilterChange();
|
if (onFilterChange) onFilterChange();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ const TicketCreateView = {
|
|||||||
|
|
||||||
// 2. Customer User pre-population with first match
|
// 2. Customer User pre-population with first match
|
||||||
try {
|
try {
|
||||||
const companies = await App.api('/api/customer-companies/search?q=cliente');
|
const companies = await App.api('/api/customer-companies/search?q=');
|
||||||
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
||||||
const defaultCompany = companies[0];
|
const defaultCompany = companies[0];
|
||||||
customerIdInput.value = defaultCompany.customer_id;
|
customerIdInput.value = defaultCompany.customer_id;
|
||||||
@@ -388,11 +388,7 @@ const TicketCreateView = {
|
|||||||
userSearchInput.addEventListener('input', () => {
|
userSearchInput.addEventListener('input', () => {
|
||||||
clearTimeout(userDebounce);
|
clearTimeout(userDebounce);
|
||||||
const q = userSearchInput.value.trim();
|
const q = userSearchInput.value.trim();
|
||||||
if (q.length < 2) {
|
// Do not block empty query to allow all results on focus
|
||||||
userSuggestionsDiv.style.display = 'none';
|
|
||||||
customerUserIdInput.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
userDebounce = setTimeout(async () => {
|
userDebounce = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -432,6 +428,11 @@ const TicketCreateView = {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
userSearchInput.addEventListener('focus', () => {
|
||||||
|
userSearchInput.value = '';
|
||||||
|
customerUserIdInput.value = '';
|
||||||
|
userSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner Autocomplete (dynamic backend search)
|
// Owner Autocomplete (dynamic backend search)
|
||||||
|
|||||||
+204
-24
@@ -6,9 +6,33 @@ const TicketDetailView = {
|
|||||||
ticketId: null,
|
ticketId: null,
|
||||||
htmlMode: false,
|
htmlMode: false,
|
||||||
originalValues: {},
|
originalValues: {},
|
||||||
|
noteAttachments: [],
|
||||||
|
|
||||||
|
updateNoteAttachmentList() {
|
||||||
|
const listEl = document.getElementById('note-file-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = this.noteAttachments.map((att, idx) => `
|
||||||
|
<div class="upload-file-item">
|
||||||
|
<span>📎</span>
|
||||||
|
<strong>${App.escapeHtml(att.filename)}</strong>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs);">(${Math.round(att.content.length * 0.75 / 1024)} KB)</span>
|
||||||
|
<button type="button" class="btn-remove" data-idx="${idx}">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Bind remove buttons
|
||||||
|
listEl.querySelectorAll('.btn-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.idx, 10);
|
||||||
|
this.noteAttachments.splice(idx, 1);
|
||||||
|
this.updateNoteAttachmentList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async render(id) {
|
async render(id) {
|
||||||
this.ticketId = id;
|
this.ticketId = id;
|
||||||
|
this.noteAttachments = [];
|
||||||
this.htmlMode = localStorage.getItem('otrs_turbo_html_mode') === 'true';
|
this.htmlMode = localStorage.getItem('otrs_turbo_html_mode') === 'true';
|
||||||
const container = document.getElementById('view-container');
|
const container = document.getElementById('view-container');
|
||||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
|
||||||
@@ -28,10 +52,10 @@ const TicketDetailView = {
|
|||||||
return sum + (isNaN(val) ? 0 : val);
|
return sum + (isNaN(val) ? 0 : val);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Display total time in the topbar
|
// Display ticket number in the topbar
|
||||||
const titleEl = document.getElementById('page-title');
|
const titleEl = document.getElementById('page-title');
|
||||||
if (titleEl) {
|
if (titleEl) {
|
||||||
titleEl.innerHTML = `Ticket #${id} <span style="font-size:0.85rem; font-weight:normal; color:var(--text-secondary); margin-left:var(--space-md); background:var(--bg-tertiary); padding:4px 10px; border-radius:var(--radius-sm); border:1px solid var(--border-light); display:inline-flex; align-items:center; gap:4px;">⏱ Tempo Consultivato: <strong>${totalTime} min</strong></span>`;
|
titleEl.innerHTML = `Ticket #${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.originalValues = {
|
this.originalValues = {
|
||||||
@@ -59,7 +83,13 @@ const TicketDetailView = {
|
|||||||
<div class="ticket-header">
|
<div class="ticket-header">
|
||||||
<div class="ticket-header-info">
|
<div class="ticket-header-info">
|
||||||
<div class="ticket-number">#${ticket.tn}</div>
|
<div class="ticket-number">#${ticket.tn}</div>
|
||||||
<h2 class="ticket-detail-title">${App.escapeHtml(ticket.title || '(senza titolo)')}</h2>
|
<h2 class="ticket-detail-title">
|
||||||
|
${data.otrsWebUrl ? `
|
||||||
|
<a href="${data.otrsWebUrl}" target="_blank" style="color:inherit; text-decoration:none; border-bottom:1px dashed transparent; transition:border-bottom 0.1s ease;" onmouseover="this.style.borderBottom='1px dashed var(--text-primary)'" onmouseout="this.style.borderBottom='transparent'" title="Apri in OTRS">
|
||||||
|
${App.escapeHtml(ticket.title || '(senza titolo)')}
|
||||||
|
</a>
|
||||||
|
` : App.escapeHtml(ticket.title || '(senza titolo)')}
|
||||||
|
</h2>
|
||||||
<div class="ticket-meta-badges">
|
<div class="ticket-meta-badges">
|
||||||
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
|
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
|
||||||
<span class="badge badge-priority" data-priority="${App.priorityIndex(ticket.priority_name)}">${ticket.priority_name}</span>
|
<span class="badge badge-priority" data-priority="${App.priorityIndex(ticket.priority_name)}">${ticket.priority_name}</span>
|
||||||
@@ -124,15 +154,19 @@ const TicketDetailView = {
|
|||||||
<input type="hidden" id="qe-customer-id" data-field="customer_id" value="${ticket.customer_id || ''}" />
|
<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 id="qe-customer-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="quick-edit-field">
|
||||||
|
<label class="quick-edit-label">Tempo (minuti)</label>
|
||||||
|
<input type="number" step="any" min="0" class="form-input" id="qe-time-unit" placeholder="minuti" style="padding: 6px 12px; height: 32px; font-size: 0.85rem;" />
|
||||||
|
</div>
|
||||||
<div style="margin-top:var(--space-md);display:flex;gap:var(--space-sm);justify-content:flex-end;">
|
<div style="margin-top:var(--space-md);display:flex;gap:var(--space-sm);justify-content:flex-end;">
|
||||||
<button class="btn btn-ghost btn-sm" id="qe-reset">Reset</button>
|
<button class="btn btn-ghost btn-sm" id="qe-reset">Reset</button>
|
||||||
<button class="btn btn-primary btn-sm" id="qe-save" disabled>Salva Modifiche</button>
|
<button class="btn btn-primary btn-sm" id="qe-save" disabled>Salva Modifiche</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add Note Form -->
|
<!-- Add Note Form -->
|
||||||
<div class="add-note-form" style="margin-bottom: var(--space-lg);">
|
<div class="add-note-form" style="margin-bottom: var(--space-lg);">
|
||||||
<div class="card-title">Aggiungi Nota</div>
|
<div class="card-title">Aggiungi Nota</div>
|
||||||
<div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);">
|
<div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);">
|
||||||
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
|
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
|
||||||
@@ -140,12 +174,21 @@ const TicketDetailView = {
|
|||||||
<div id="note-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-md);">
|
<div id="note-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-md);">
|
||||||
<div id="note-body-editor" style="min-height: 180px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
|
<div id="note-body-editor" style="min-height: 180px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:var(--space-md); justify-content:flex-end; align-items:center; margin-top:var(--space-md);">
|
<!-- Attachments List for note -->
|
||||||
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
|
<div class="upload-file-list" id="note-file-list" style="margin-bottom: var(--space-md);"></div>
|
||||||
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
<div style="display:flex; gap:var(--space-md); justify-content:space-between; align-items:center; margin-top:var(--space-md);">
|
||||||
Invia Nota
|
<div>
|
||||||
</button>
|
<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>
|
||||||
|
<div style="display:flex; gap:var(--space-md); align-items:center;">
|
||||||
|
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
|
||||||
|
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
||||||
|
Invia Nota
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -165,7 +208,7 @@ const TicketDetailView = {
|
|||||||
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
||||||
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
||||||
|
|
||||||
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
|
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
||||||
@@ -177,8 +220,21 @@ const TicketDetailView = {
|
|||||||
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||||
${a.time_unit ? `<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ ${parseFloat(a.time_unit)} min</span>` : ''}
|
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
|
||||||
|
${a.time_unit ? `
|
||||||
|
<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ <span class="time-value-display">${parseFloat(a.time_unit)}</span> min</span>
|
||||||
|
` : ''}
|
||||||
|
<button class="retrodate-btn-edit btn-edit-article-time" style="background:none; border:none; cursor:pointer; font-size:0.75rem; padding: 2px 4px; display:inline-flex; align-items:center;" title="${a.time_unit ? 'Modifica Tempo' : 'Aggiungi Tempo'}">
|
||||||
|
${a.time_unit ? '✏️' : '⏱+✏️'}
|
||||||
|
</button>
|
||||||
|
<div class="time-editor-popover" style="display:none; position:absolute; right:0; top: 100%; background:var(--bg-card); border:1px solid var(--border-subtle); padding:var(--space-xs); border-radius:var(--radius-sm); z-index:100; box-shadow:var(--shadow-md); margin-top:4px;">
|
||||||
|
<input type="number" step="any" min="0" class="form-input input-article-time" value="${a.time_unit ? parseFloat(a.time_unit) : 0}" style="width:70px; height:24px; padding:2px 6px; font-size:0.8rem; margin-bottom:4px; display:block;" />
|
||||||
|
<div style="display:flex; gap:4px; justify-content:flex-end;">
|
||||||
|
<button class="btn btn-primary btn-sm btn-save-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✓</button>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-cancel-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="retrodate-container" data-article-id="${a.article_id}">
|
<div class="retrodate-container" data-article-id="${a.article_id}">
|
||||||
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
||||||
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
|
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
|
||||||
@@ -361,6 +417,7 @@ const TicketDetailView = {
|
|||||||
const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id');
|
const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id');
|
||||||
const saveBtn = document.getElementById('qe-save');
|
const saveBtn = document.getElementById('qe-save');
|
||||||
const resetBtn = document.getElementById('qe-reset');
|
const resetBtn = document.getElementById('qe-reset');
|
||||||
|
const timeUnitInput = document.getElementById('qe-time-unit');
|
||||||
|
|
||||||
const checkChanges = () => {
|
const checkChanges = () => {
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
@@ -372,10 +429,20 @@ const TicketDetailView = {
|
|||||||
el.classList.toggle('changed', changed);
|
el.classList.toggle('changed', changed);
|
||||||
if (changed) hasChanges = true;
|
if (changed) hasChanges = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const timeVal = parseFloat(timeUnitInput ? timeUnitInput.value : '0') || 0;
|
||||||
|
if (timeVal > 0) {
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.disabled = !hasChanges;
|
saveBtn.disabled = !hasChanges;
|
||||||
};
|
};
|
||||||
|
|
||||||
fields.forEach(el => el.addEventListener('change', checkChanges));
|
fields.forEach(el => el.addEventListener('change', checkChanges));
|
||||||
|
if (timeUnitInput) {
|
||||||
|
timeUnitInput.addEventListener('input', checkChanges);
|
||||||
|
timeUnitInput.addEventListener('change', checkChanges);
|
||||||
|
}
|
||||||
|
|
||||||
// Customer User Autocomplete inside Quick Edit
|
// Customer User Autocomplete inside Quick Edit
|
||||||
const customerSearchInput = document.getElementById('qe-customer-search');
|
const customerSearchInput = document.getElementById('qe-customer-search');
|
||||||
@@ -388,14 +455,7 @@ const TicketDetailView = {
|
|||||||
customerSearchInput.addEventListener('input', () => {
|
customerSearchInput.addEventListener('input', () => {
|
||||||
clearTimeout(customerDebounce);
|
clearTimeout(customerDebounce);
|
||||||
const q = customerSearchInput.value.trim();
|
const q = customerSearchInput.value.trim();
|
||||||
if (q.length < 2) {
|
// Do not block empty query to allow all results on focus
|
||||||
customerSuggestionsDiv.style.display = 'none';
|
|
||||||
customerUserIdInput.value = '';
|
|
||||||
customerIdInput.value = '';
|
|
||||||
customerUserIdInput.dispatchEvent(new Event('change'));
|
|
||||||
customerIdInput.dispatchEvent(new Event('change'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
customerDebounce = setTimeout(async () => {
|
customerDebounce = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -432,6 +492,14 @@ const TicketDetailView = {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
customerSearchInput.addEventListener('focus', () => {
|
||||||
|
customerSearchInput.value = '';
|
||||||
|
customerUserIdInput.value = '';
|
||||||
|
customerIdInput.value = '';
|
||||||
|
customerUserIdInput.dispatchEvent(new Event('change'));
|
||||||
|
customerIdInput.dispatchEvent(new Event('change'));
|
||||||
|
customerSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close suggestions on click outside
|
// Close suggestions on click outside
|
||||||
@@ -452,6 +520,9 @@ const TicketDetailView = {
|
|||||||
const last = this.originalValues['customer_last'] || '';
|
const last = this.originalValues['customer_last'] || '';
|
||||||
customerSearchInput.value = first ? `${first} ${last}` : (this.originalValues['customer_user_id'] || '');
|
customerSearchInput.value = first ? `${first} ${last}` : (this.originalValues['customer_user_id'] || '');
|
||||||
}
|
}
|
||||||
|
if (timeUnitInput) {
|
||||||
|
timeUnitInput.value = '';
|
||||||
|
}
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -479,6 +550,13 @@ const TicketDetailView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (timeUnitInput && timeUnitInput.value.trim()) {
|
||||||
|
const timeVal = parseFloat(timeUnitInput.value.trim());
|
||||||
|
if (!isNaN(timeVal) && timeVal > 0) {
|
||||||
|
updates.time_unit = timeVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) return;
|
if (Object.keys(updates).length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -537,9 +615,15 @@ const TicketDetailView = {
|
|||||||
|
|
||||||
const res = await App.api(`/api/tickets/${this.ticketId}/articles`, {
|
const res = await App.api(`/api/tickets/${this.ticketId}/articles`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ subject, body, time_unit }),
|
body: JSON.stringify({
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
time_unit,
|
||||||
|
attachments: this.noteAttachments.length > 0 ? this.noteAttachments : undefined
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.noteAttachments = []; // Clear attachments
|
||||||
Toast.success(res.message || 'Nota aggiunta!');
|
Toast.success(res.message || 'Nota aggiunta!');
|
||||||
App.updateDailyTimer();
|
App.updateDailyTimer();
|
||||||
this.render(this.ticketId);
|
this.render(this.ticketId);
|
||||||
@@ -550,6 +634,32 @@ const TicketDetailView = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note Attachments Upload Handlers
|
||||||
|
const noteAttachBtn = document.getElementById('btn-note-add-attachments');
|
||||||
|
const noteFileInput = document.getElementById('note-attachments');
|
||||||
|
|
||||||
|
if (noteAttachBtn && noteFileInput) {
|
||||||
|
noteAttachBtn.addEventListener('click', () => noteFileInput.click());
|
||||||
|
|
||||||
|
noteFileInput.addEventListener('change', (e) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
for (const file of files) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64Data = reader.result.split(',')[1];
|
||||||
|
this.noteAttachments.push({
|
||||||
|
filename: file.name,
|
||||||
|
content: base64Data,
|
||||||
|
content_type: file.type
|
||||||
|
});
|
||||||
|
this.updateNoteAttachmentList();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
noteFileInput.value = ''; // Reset input
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Retrodate Ticket Event Listeners
|
// Retrodate Ticket Event Listeners
|
||||||
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
|
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
|
||||||
const ticketDateEditor = document.getElementById('ticket-date-editor');
|
const ticketDateEditor = document.getElementById('ticket-date-editor');
|
||||||
@@ -638,6 +748,76 @@ const TicketDetailView = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Time Edit Event Listeners (Issue 21)
|
||||||
|
document.querySelectorAll('.time-edit-container[data-article-id]').forEach(container => {
|
||||||
|
const articleId = container.dataset.articleId;
|
||||||
|
const btnEdit = container.querySelector('.btn-edit-article-time');
|
||||||
|
const popover = container.querySelector('.time-editor-popover');
|
||||||
|
const btnSave = container.querySelector('.btn-save-article-time');
|
||||||
|
const btnCancel = container.querySelector('.btn-cancel-article-time');
|
||||||
|
const input = container.querySelector('.input-article-time');
|
||||||
|
|
||||||
|
if (btnEdit) {
|
||||||
|
btnEdit.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Close all other open time edit popovers
|
||||||
|
document.querySelectorAll('.time-editor-popover').forEach(p => {
|
||||||
|
if (p !== popover) p.style.display = 'none';
|
||||||
|
});
|
||||||
|
const isOpen = popover.style.display === 'block';
|
||||||
|
popover.style.display = isOpen ? 'none' : 'block';
|
||||||
|
if (!isOpen) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnCancel) {
|
||||||
|
btnCancel.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
popover.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnSave) {
|
||||||
|
btnSave.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const val = parseFloat(input.value);
|
||||||
|
if (isNaN(val) || val < 0) {
|
||||||
|
Toast.warning('Inserisci un valore numerico valido maggiore o uguale a 0.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
btnSave.disabled = true;
|
||||||
|
await App.api(`/api/tickets/articles/${articleId}/time`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ time_unit: val })
|
||||||
|
});
|
||||||
|
Toast.success('Tempo consuntivato aggiornato con successo!');
|
||||||
|
popover.style.display = 'none';
|
||||||
|
this.render(this.ticketId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante l\'aggiornamento del tempo: ' + err.message);
|
||||||
|
btnSave.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop propagation so clicking inside doesn't close it
|
||||||
|
if (popover) {
|
||||||
|
popover.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close popovers clicking outside
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.time-editor-popover').forEach(p => {
|
||||||
|
p.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
// HTML Mode Toggle Listener
|
// HTML Mode Toggle Listener
|
||||||
const htmlToggle = document.getElementById('html-toggle');
|
const htmlToggle = document.getElementById('html-toggle');
|
||||||
if (htmlToggle) {
|
if (htmlToggle) {
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ const TicketListView = {
|
|||||||
|
|
||||||
// Build query params
|
// Build query params
|
||||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||||
if (isMyTickets) {
|
Filters.currentMode = isMyTickets ? 'my' : 'general';
|
||||||
|
Filters.load(); // Load state for current mode
|
||||||
|
|
||||||
|
if (isMyTickets && !Filters.state.user_id) {
|
||||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
Filters.state.user_id = activeAgentId;
|
Filters.state.user_id = activeAgentId;
|
||||||
|
Filters.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = Filters.toQueryParams();
|
const params = Filters.toQueryParams();
|
||||||
@@ -99,36 +103,52 @@ const TicketListView = {
|
|||||||
|
|
||||||
${Filters.renderBar(App.lookups)}
|
${Filters.renderBar(App.lookups)}
|
||||||
|
|
||||||
|
<!-- Pagination Top -->
|
||||||
|
${this.renderPagination(page, per_page, total, total_pages, true)}
|
||||||
|
|
||||||
<!-- Ticket Table -->
|
<!-- Ticket Table -->
|
||||||
<div class="ticket-table-wrapper">
|
<div class="ticket-table-wrapper">
|
||||||
<table class="ticket-table" id="ticket-table">
|
<table class="ticket-table" id="ticket-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
<th style="width: 75px;" class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</th>
|
||||||
|
<th style="width: 125px;" class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||||||
|
<th style="width: 90px;" class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
||||||
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
<th class="sortable ${this.sortBy === 'title' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="title">Titolo</th>
|
||||||
<th class="sortable ${this.sortBy === 'state' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="state">Stato</th>
|
<th style="width: 130px;" class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
||||||
<th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
|
<th style="width: 120px;">Owner</th>
|
||||||
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
<th style="width: 140px;">Cliente</th>
|
||||||
<th>Owner</th>
|
<th style="width: 80px;" class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
|
||||||
<th>Cliente</th>
|
|
||||||
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${tickets.length > 0 ? tickets.map(t => `
|
${tickets.length > 0 ? tickets.map(t => {
|
||||||
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
|
const displayQueue = t.queue_name.includes('::') ? t.queue_name.split('::').pop() : t.queue_name;
|
||||||
<td><span class="ticket-tn"><a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a></span></td>
|
const shortQueue = displayQueue.length > 15 ? displayQueue.substring(0, 12) + '...' : displayQueue;
|
||||||
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
|
return `
|
||||||
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
|
||||||
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
|
<td>
|
||||||
<td><span class="badge badge-queue">${t.queue_name}</span></td>
|
<span class="ticket-tn" style="display:inline-flex; align-items:center;">
|
||||||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
<a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a>
|
||||||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
${data.otrsBaseUrl ? `
|
||||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
<a href="${data.otrsBaseUrl}index.pl?Action=AgentTicketZoom;TicketID=${t.id}" target="_blank" title="Apri in OTRS" onclick="event.stopPropagation()" style="display:inline-flex; align-items:center; text-decoration:none;">
|
||||||
</tr>
|
<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; margin-left:6px; vertical-align:middle; line-height:16px;">O</span>
|
||||||
`).join('') : `
|
</a>
|
||||||
|
` : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
||||||
|
<td><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
||||||
|
<td class="ticket-title-cell"><a href="#/tickets/${t.id}" class="ticket-title-link" onclick="event.stopPropagation()">${App.escapeHtml(t.title || '(senza titolo)')}</a></td>
|
||||||
|
<td class="queue-cell"><span class="badge badge-queue">${App.escapeHtml(shortQueue)}</span><div class="queue-tooltip">${App.escapeHtml(t.queue_name)}</div></td>
|
||||||
|
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
||||||
|
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
||||||
|
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('') : `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7">
|
<td colspan="8">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📭</div>
|
<div class="empty-state-icon">📭</div>
|
||||||
<div class="empty-state-text">Nessun ticket trovato</div>
|
<div class="empty-state-text">Nessun ticket trovato</div>
|
||||||
@@ -141,9 +161,16 @@ const TicketListView = {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination Bottom -->
|
||||||
${total_pages > 1 ? `
|
${this.renderPagination(page, per_page, total, total_pages, false)}
|
||||||
<div class="pagination">
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPagination(page, per_page, total, total_pages, isTop) {
|
||||||
|
const marginStyle = isTop ? 'margin-bottom: var(--space-md); margin-top: 0;' : 'margin-top: var(--space-md); margin-bottom: 0;';
|
||||||
|
if (total_pages > 1) {
|
||||||
|
return `
|
||||||
|
<div class="pagination" style="${marginStyle}">
|
||||||
<div class="pagination-info">
|
<div class="pagination-info">
|
||||||
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
Mostrando ${((page - 1) * per_page) + 1}–${Math.min(page * per_page, total)} di ${total} ticket
|
||||||
</div>
|
</div>
|
||||||
@@ -155,13 +182,15 @@ const TicketListView = {
|
|||||||
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
|
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : `
|
`;
|
||||||
<div class="pagination">
|
} else {
|
||||||
|
return `
|
||||||
|
<div class="pagination" style="${marginStyle}">
|
||||||
<div class="pagination-info">${total} ticket totali</div>
|
<div class="pagination-info">${total} ticket totali</div>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`;
|
||||||
`;
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
renderPageButtons(current, total) {
|
renderPageButtons(current, total) {
|
||||||
@@ -312,12 +341,7 @@ const TicketListView = {
|
|||||||
batchCustomerSearchInput.addEventListener('input', () => {
|
batchCustomerSearchInput.addEventListener('input', () => {
|
||||||
clearTimeout(batchCustomerDebounce);
|
clearTimeout(batchCustomerDebounce);
|
||||||
const q = batchCustomerSearchInput.value.trim();
|
const q = batchCustomerSearchInput.value.trim();
|
||||||
if (q.length < 2) {
|
// Do not block empty query to allow all results on focus
|
||||||
batchCustomerSuggestionsDiv.style.display = 'none';
|
|
||||||
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = '';
|
|
||||||
if (batchCustomerIdInput) batchCustomerIdInput.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
batchCustomerDebounce = setTimeout(async () => {
|
batchCustomerDebounce = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -352,6 +376,12 @@ const TicketListView = {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
batchCustomerSearchInput.addEventListener('focus', () => {
|
||||||
|
batchCustomerSearchInput.value = '';
|
||||||
|
if (batchCustomerUserIdInput) batchCustomerUserIdInput.value = '';
|
||||||
|
if (batchCustomerIdInput) batchCustomerIdInput.value = '';
|
||||||
|
batchCustomerSearchInput.dispatchEvent(new Event('input'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close suggestions on click outside
|
// Close suggestions on click outside
|
||||||
|
|||||||
+22
-14
@@ -168,22 +168,30 @@ router.get('/lock-types', async (req, res) => {
|
|||||||
// GET /api/customer-companies/search — Search customer companies
|
// GET /api/customer-companies/search — Search customer companies
|
||||||
router.get('/customer-companies/search', async (req, res) => {
|
router.get('/customer-companies/search', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { q } = req.query;
|
const { q = '' } = req.query;
|
||||||
|
let result;
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return res.json([]);
|
result = await pool.query(
|
||||||
|
`SELECT customer_id, name
|
||||||
|
FROM customer_company
|
||||||
|
WHERE valid_id = 1
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 20`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const searchTerm = `%${q}%`;
|
||||||
|
result = await pool.query(
|
||||||
|
`SELECT customer_id, name
|
||||||
|
FROM customer_company
|
||||||
|
WHERE valid_id = 1 AND (
|
||||||
|
customer_id ILIKE $1 OR
|
||||||
|
name ILIKE $1
|
||||||
|
)
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 20`,
|
||||||
|
[searchTerm]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const searchTerm = `%${q}%`;
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT customer_id, name
|
|
||||||
FROM customer_company
|
|
||||||
WHERE valid_id = 1 AND (
|
|
||||||
customer_id ILIKE $1 OR
|
|
||||||
name ILIKE $1
|
|
||||||
)
|
|
||||||
ORDER BY name
|
|
||||||
LIMIT 20`,
|
|
||||||
[searchTerm]
|
|
||||||
);
|
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error searching customer companies:', err);
|
console.error('Error searching customer companies:', err);
|
||||||
|
|||||||
+323
-41
@@ -66,8 +66,21 @@ router.get('/', async (req, res) => {
|
|||||||
params.push(parseInt(queue_id));
|
params.push(parseInt(queue_id));
|
||||||
}
|
}
|
||||||
if (state_id) {
|
if (state_id) {
|
||||||
conditions.push(`t.ticket_state_id = $${paramIdx++}`);
|
let stateIds = [];
|
||||||
params.push(parseInt(state_id));
|
if (Array.isArray(state_id)) {
|
||||||
|
stateIds = state_id.map(id => parseInt(id)).filter(id => !isNaN(id));
|
||||||
|
} else if (typeof state_id === 'string') {
|
||||||
|
stateIds = state_id.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id));
|
||||||
|
} else {
|
||||||
|
const parsed = parseInt(state_id);
|
||||||
|
if (!isNaN(parsed)) stateIds.push(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateIds.length > 0) {
|
||||||
|
const placeholders = stateIds.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.ticket_state_id IN (${placeholders})`);
|
||||||
|
params.push(...stateIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (priority_id) {
|
if (priority_id) {
|
||||||
conditions.push(`t.ticket_priority_id = $${paramIdx++}`);
|
conditions.push(`t.ticket_priority_id = $${paramIdx++}`);
|
||||||
@@ -162,12 +175,22 @@ router.get('/', async (req, res) => {
|
|||||||
[...params, parseInt(per_page), offset]
|
[...params, parseInt(per_page), offset]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let otrsBaseUrl = null;
|
||||||
|
const apiUrl = process.env.OTRS_API_URL;
|
||||||
|
if (apiUrl) {
|
||||||
|
const idx = apiUrl.indexOf('/otrs/');
|
||||||
|
if (idx !== -1) {
|
||||||
|
otrsBaseUrl = apiUrl.substring(0, idx + 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
tickets: result.rows,
|
tickets: result.rows,
|
||||||
total,
|
total,
|
||||||
page: parseInt(page),
|
page: parseInt(page),
|
||||||
per_page: parseInt(per_page),
|
per_page: parseInt(per_page),
|
||||||
total_pages: Math.ceil(total / parseInt(per_page)),
|
total_pages: Math.ceil(total / parseInt(per_page)),
|
||||||
|
otrsBaseUrl,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching tickets:', err);
|
console.error('Error fetching tickets:', err);
|
||||||
@@ -252,10 +275,21 @@ router.get('/:id', async (req, res) => {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let otrsWebUrl = null;
|
||||||
|
const apiUrl = process.env.OTRS_API_URL;
|
||||||
|
if (apiUrl) {
|
||||||
|
const idx = apiUrl.indexOf('/otrs/');
|
||||||
|
if (idx !== -1) {
|
||||||
|
const base = apiUrl.substring(0, idx + 6);
|
||||||
|
otrsWebUrl = `${base}index.pl?Action=AgentTicketZoom;TicketID=${id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ticket: ticketResult.rows[0],
|
ticket: ticketResult.rows[0],
|
||||||
articles: articlesResult.rows,
|
articles: articlesResult.rows,
|
||||||
attachments: attachmentsResult.rows,
|
attachments: attachmentsResult.rows,
|
||||||
|
otrsWebUrl,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching ticket detail:', err);
|
console.error('Error fetching ticket detail:', err);
|
||||||
@@ -387,7 +421,13 @@ router.post('/', async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create initial article if body or attachments are provided
|
// Create initial article if body or attachments are provided
|
||||||
if (body || (attachments && attachments.length > 0)) {
|
let finalBody = body;
|
||||||
|
const isBodyEmpty = !finalBody || finalBody.trim() === '' || finalBody.trim() === '<p><br></p>';
|
||||||
|
if (isBodyEmpty) {
|
||||||
|
finalBody = subject || title || 'Nuovo ticket';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalBody || (attachments && attachments.length > 0)) {
|
||||||
// Determine sender type (customer vs agent) and sender name/email
|
// Determine sender type (customer vs agent) and sender name/email
|
||||||
let senderTypeName = 'agent';
|
let senderTypeName = 'agent';
|
||||||
let customerFrom = 'OTRS Turbo Agent';
|
let customerFrom = 'OTRS Turbo Agent';
|
||||||
@@ -429,8 +469,7 @@ router.post('/', async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const articleId = articleResult.rows[0].id;
|
const articleId = articleResult.rows[0].id;
|
||||||
const finalBody = body || 'File allegati in creazione';
|
const isHtml = finalBody && /<[a-z][\s\S]*>/i.test(finalBody);
|
||||||
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
|
||||||
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
|
const contentType = isHtml ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -446,6 +485,25 @@ router.post('/', async (req, res) => {
|
|||||||
[articleId, customerFrom, subject || title, finalBody, contentType, operatorId]
|
[articleId, customerFrom, subject || title, finalBody, contentType, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If HTML content, create article_data_mime_attachment for OTRS CE HTML rendering (file-1)
|
||||||
|
if (isHtml) {
|
||||||
|
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${finalBody}</body></html>`;
|
||||||
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
||||||
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
||||||
|
const base64Body = binaryBody.toString('base64');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime_attachment (
|
||||||
|
article_id, filename, content_size, content_type, disposition, content,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
||||||
|
NOW(), $4, NOW(), $4
|
||||||
|
)`,
|
||||||
|
[articleId, String(contentSize), base64Body, operatorId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Insert attachments if any
|
// Insert attachments if any
|
||||||
if (attachments && Array.isArray(attachments)) {
|
if (attachments && Array.isArray(attachments)) {
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
@@ -460,7 +518,7 @@ router.post('/', async (req, res) => {
|
|||||||
att.filename,
|
att.filename,
|
||||||
contentBuffer.length,
|
contentBuffer.length,
|
||||||
att.content_type || 'application/octet-stream',
|
att.content_type || 'application/octet-stream',
|
||||||
contentBuffer,
|
att.content, // OTRS CE expects base64 string directly
|
||||||
operatorId
|
operatorId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -491,6 +549,24 @@ router.patch('/:id', async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title }
|
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title }
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
const timeUnit = parseFloat(updates.time_unit);
|
||||||
|
|
||||||
|
// Fetch current ticket first (needed for both REST dummy fields and database fallback)
|
||||||
|
let current;
|
||||||
|
try {
|
||||||
|
const currentResult = await pool.query(
|
||||||
|
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
|
||||||
|
FROM ticket WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (currentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Ticket non trovato' });
|
||||||
|
}
|
||||||
|
current = currentResult.rows[0];
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('Error fetching current ticket:', dbErr);
|
||||||
|
return res.status(500).json({ error: dbErr.message });
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Try to update via REST API if configured
|
// 1. Try to update via REST API if configured
|
||||||
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
|
if (process.env.OTRS_API_URL && process.env.OTRS_API_USER) {
|
||||||
@@ -506,6 +582,11 @@ router.patch('/:id', async (req, res) => {
|
|||||||
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_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) ticketFields.CustomerUser = updates.customer_user_id;
|
||||||
|
|
||||||
|
// If ticketFields is empty but we have a timeUnit to log, we must supply a dummy field (e.g. StateID) so the Ticket parameter is not empty
|
||||||
|
if (Object.keys(ticketFields).length === 0 && !isNaN(timeUnit) && timeUnit > 0) {
|
||||||
|
ticketFields.StateID = current.ticket_state_id;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto sblocco check
|
// Auto sblocco check
|
||||||
if (updates.ticket_state_id) {
|
if (updates.ticket_state_id) {
|
||||||
const stateTypeRes = await pool.query(
|
const stateTypeRes = await pool.query(
|
||||||
@@ -523,10 +604,22 @@ router.patch('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(ticketFields).length > 0) {
|
if (Object.keys(ticketFields).length > 0 || (!isNaN(timeUnit) && timeUnit > 0)) {
|
||||||
const result = await otrsRequest('PATCH', `/Ticket/${id}`, {
|
const reqBody = {
|
||||||
Ticket: ticketFields
|
Ticket: ticketFields
|
||||||
});
|
};
|
||||||
|
if (!isNaN(timeUnit) && timeUnit > 0) {
|
||||||
|
reqBody.Article = {
|
||||||
|
CommunicationChannel: 'Internal',
|
||||||
|
SenderType: 'agent',
|
||||||
|
Subject: 'Consuntivazione',
|
||||||
|
Body: 'Consuntivazione',
|
||||||
|
ContentType: 'text/html; charset=utf8',
|
||||||
|
TimeUnit: timeUnit,
|
||||||
|
TimeUnits: timeUnit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = await otrsRequest('PATCH', `/Ticket/${id}`, reqBody);
|
||||||
return res.json({ message: 'Ticket aggiornato! (via API REST)', result });
|
return res.json({ message: 'Ticket aggiornato! (via API REST)', result });
|
||||||
}
|
}
|
||||||
return res.json({ message: 'Nessuna modifica rilevata' });
|
return res.json({ message: 'Nessuna modifica rilevata' });
|
||||||
@@ -539,24 +632,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
// 2. Direct database update fallback
|
// 2. Direct database update fallback
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
|
||||||
const updates = req.body; // { ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title }
|
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
|
||||||
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Fetch current ticket for history comparison
|
|
||||||
const currentResult = await client.query(
|
|
||||||
`SELECT ticket_state_id, ticket_priority_id, queue_id, user_id, type_id, title, ticket_lock_id, customer_id, customer_user_id
|
|
||||||
FROM ticket WHERE id = $1`,
|
|
||||||
[id]
|
|
||||||
);
|
|
||||||
if (currentResult.rows.length === 0) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
return res.status(404).json({ error: 'Ticket not found' });
|
|
||||||
}
|
|
||||||
const current = currentResult.rows[0];
|
|
||||||
|
|
||||||
// If state is changing, check if the target state is a closed state type to auto-unlock
|
// If state is changing, check if the target state is a closed state type to auto-unlock
|
||||||
if (updates.ticket_state_id && updates.ticket_state_id !== current.ticket_state_id) {
|
if (updates.ticket_state_id && updates.ticket_state_id !== current.ticket_state_id) {
|
||||||
const stateTypeRes = await client.query(
|
const stateTypeRes = await client.query(
|
||||||
@@ -587,21 +664,32 @@ router.patch('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setClauses.length === 0) {
|
const timeUnitVal = parseFloat(updates.time_unit);
|
||||||
|
const hasTimeUnit = !isNaN(timeUnitVal) && timeUnitVal > 0;
|
||||||
|
|
||||||
|
if (setClauses.length === 0 && !hasTimeUnit) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
return res.json({ message: 'No changes detected' });
|
return res.json({ message: 'No changes detected' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always update change_time and change_by
|
if (setClauses.length > 0) {
|
||||||
setClauses.push(`change_time = NOW()`);
|
// Always update change_time and change_by
|
||||||
setClauses.push(`change_by = $${pIdx++}`);
|
setClauses.push(`change_time = NOW()`);
|
||||||
setParams.push(operatorId);
|
setClauses.push(`change_by = $${pIdx++}`);
|
||||||
setParams.push(parseInt(id));
|
setParams.push(operatorId);
|
||||||
|
setParams.push(parseInt(id));
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE ticket SET ${setClauses.join(', ')} WHERE id = $${pIdx}`,
|
`UPDATE ticket SET ${setClauses.join(', ')} WHERE id = $${pIdx}`,
|
||||||
setParams
|
setParams
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// If there are no fields modified but we have a time_unit, we still update change_time/change_by
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket SET change_time = NOW(), change_by = $1 WHERE id = $2`,
|
||||||
|
[operatorId, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Record history entries for each changed field
|
// Record history entries for each changed field
|
||||||
const historyTypeMap = {
|
const historyTypeMap = {
|
||||||
@@ -657,6 +745,79 @@ router.patch('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If time_unit is provided, insert a system note article and log the time
|
||||||
|
if (hasTimeUnit) {
|
||||||
|
// Get sender type for "agent"
|
||||||
|
const senderResult = await client.query(
|
||||||
|
`SELECT id FROM article_sender_type WHERE name = 'agent'`
|
||||||
|
);
|
||||||
|
const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
// Get channel for "Internal"
|
||||||
|
const channelResult = await client.query(
|
||||||
|
`SELECT id FROM communication_channel WHERE name = 'Internal'`
|
||||||
|
);
|
||||||
|
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
|
||||||
|
|
||||||
|
// Create article
|
||||||
|
const articleResult = await client.query(
|
||||||
|
`INSERT INTO article (
|
||||||
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
|
is_visible_for_customer, search_index_needs_rebuild,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, 0, 1, NOW(), $4, NOW(), $4
|
||||||
|
) RETURNING id`,
|
||||||
|
[id, senderTypeId, channelId, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const articleId = articleResult.rows[0].id;
|
||||||
|
const now = new Date();
|
||||||
|
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
const noteBody = 'Consuntivazione';
|
||||||
|
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${noteBody}</body></html>`;
|
||||||
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
||||||
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
||||||
|
const base64Body = binaryBody.toString('base64');
|
||||||
|
|
||||||
|
// Create article_data_mime
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime (
|
||||||
|
article_id, a_from, a_to, a_reply_to, a_cc, a_bcc, a_subject, a_body,
|
||||||
|
a_message_id, a_in_reply_to, a_references,
|
||||||
|
a_content_type, incoming_time, content_path,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, '', '', '', '', $3, $4,
|
||||||
|
'', '', '',
|
||||||
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
||||||
|
NOW(), $6, NOW(), $6
|
||||||
|
)`,
|
||||||
|
[articleId, 'OTRS Turbo Agent', 'Consuntivazione', htmlBody, contentPath, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create article_data_mime_attachment for OTRS CE HTML rendering
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime_attachment (
|
||||||
|
article_id, filename, content_size, content_type, disposition, content,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES (
|
||||||
|
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
||||||
|
NOW(), $4, NOW(), $4
|
||||||
|
)`,
|
||||||
|
[articleId, String(contentSize), base64Body, operatorId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log the time in time_accounting
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO time_accounting (
|
||||||
|
ticket_id, article_id, time_unit,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
|
||||||
|
[id, articleId, timeUnitVal, operatorId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
res.json({ message: 'Ticket aggiornato! (via DB)' });
|
res.json({ message: 'Ticket aggiornato! (via DB)' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -707,7 +868,7 @@ router.get('/:id/articles', async (req, res) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
router.post('/:id/articles', async (req, res) => {
|
router.post('/:id/articles', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { subject, body, is_visible_for_customer = 0, time_unit } = req.body;
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = req.body;
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
const isHtml = body && /<[a-z][\s\S]*>/i.test(body);
|
||||||
@@ -729,6 +890,15 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
|
|
||||||
if (time_unit) {
|
if (time_unit) {
|
||||||
payload.Article.TimeUnit = parseFloat(time_unit);
|
payload.Article.TimeUnit = parseFloat(time_unit);
|
||||||
|
payload.Article.TimeUnits = parseFloat(time_unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments && Array.isArray(attachments)) {
|
||||||
|
payload.Article.Attachment = attachments.map(att => ({
|
||||||
|
Content: att.content,
|
||||||
|
ContentType: att.content_type || 'application/octet-stream',
|
||||||
|
Filename: att.filename
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload);
|
const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload);
|
||||||
@@ -747,7 +917,7 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { subject, body, is_visible_for_customer = 0, time_unit } = req.body;
|
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = req.body;
|
||||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
@@ -805,10 +975,11 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentType, contentPath, operatorId]
|
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentType, contentPath, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and raw Buffer)
|
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and base64 encoded text)
|
||||||
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body}</body></html>`;
|
const htmlBody = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body}</body></html>`;
|
||||||
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
const binaryBody = Buffer.from(htmlBody, 'utf-8');
|
||||||
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
||||||
|
const base64Body = binaryBody.toString('base64');
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO article_data_mime_attachment (
|
`INSERT INTO article_data_mime_attachment (
|
||||||
@@ -818,9 +989,30 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
|
||||||
NOW(), $4, NOW(), $4
|
NOW(), $4, NOW(), $4
|
||||||
)`,
|
)`,
|
||||||
[articleId, String(contentSize), binaryBody, operatorId]
|
[articleId, String(contentSize), base64Body, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Insert additional attachments if any
|
||||||
|
if (attachments && Array.isArray(attachments)) {
|
||||||
|
for (const att of attachments) {
|
||||||
|
const contentBuffer = Buffer.from(att.content, 'base64');
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO article_data_mime_attachment (
|
||||||
|
article_id, filename, content_size, content_type, disposition, content,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, $2, $3, $4, 'attachment', $5, NOW(), $6, NOW(), $6)`,
|
||||||
|
[
|
||||||
|
articleId,
|
||||||
|
att.filename,
|
||||||
|
contentBuffer.length,
|
||||||
|
att.content_type || 'application/octet-stream',
|
||||||
|
att.content, // base64 string directly
|
||||||
|
operatorId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If time_unit is provided, insert into time_accounting
|
// If time_unit is provided, insert into time_accounting
|
||||||
if (time_unit !== undefined && time_unit !== null && time_unit !== '') {
|
if (time_unit !== undefined && time_unit !== null && time_unit !== '') {
|
||||||
const parsedTime = parseFloat(time_unit);
|
const parsedTime = parseFloat(time_unit);
|
||||||
@@ -1051,9 +1243,35 @@ router.get('/attachments/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const attachment = result.rows[0];
|
const attachment = result.rows[0];
|
||||||
|
|
||||||
|
// Decode base64 if necessary
|
||||||
|
let contentBuffer;
|
||||||
|
if (attachment.content) {
|
||||||
|
let contentStr = '';
|
||||||
|
if (Buffer.isBuffer(attachment.content)) {
|
||||||
|
contentStr = attachment.content.toString('utf-8');
|
||||||
|
} else if (typeof attachment.content === 'string') {
|
||||||
|
contentStr = attachment.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = contentStr.replace(/\s+/g, '');
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (cleaned.length % 4 === 0 && base64Regex.test(cleaned)) {
|
||||||
|
try {
|
||||||
|
contentBuffer = Buffer.from(cleaned, 'base64');
|
||||||
|
} catch (e) {
|
||||||
|
contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentBuffer = Buffer.isBuffer(attachment.content) ? attachment.content : Buffer.from(attachment.content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentBuffer = Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeFilename = attachment.filename || 'attachment';
|
||||||
res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream');
|
res.setHeader('Content-Type', attachment.content_type || 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`);
|
||||||
res.send(attachment.content);
|
res.send(contentBuffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Errore download allegato:', err);
|
console.error('Errore download allegato:', err);
|
||||||
res.status(500).send(err.message);
|
res.status(500).send(err.message);
|
||||||
@@ -1174,4 +1392,68 @@ router.post('/merge', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PUT /api/articles/:articleId/time — Update time accounted for an article
|
||||||
|
// ============================================================
|
||||||
|
router.put('/articles/:articleId/time', async (req, res) => {
|
||||||
|
const { articleId } = req.params;
|
||||||
|
const { time_unit } = req.body;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
const parsedTime = parseFloat(time_unit);
|
||||||
|
|
||||||
|
if (isNaN(parsedTime) || parsedTime < 0) {
|
||||||
|
return res.status(400).json({ error: 'Il tempo specificato deve essere un numero maggiore o uguale a 0' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 1. Check if a record already exists in time_accounting
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT id FROM time_accounting WHERE article_id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
// Update
|
||||||
|
await client.query(
|
||||||
|
`UPDATE time_accounting
|
||||||
|
SET time_unit = $1, change_time = NOW(), change_by = $2
|
||||||
|
WHERE article_id = $3`,
|
||||||
|
[parsedTime, operatorId, articleId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Find ticket_id from article
|
||||||
|
const artRes = await client.query(
|
||||||
|
`SELECT ticket_id FROM article WHERE id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
if (artRes.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'Articolo non trovato' });
|
||||||
|
}
|
||||||
|
const ticketId = artRes.rows[0].ticket_id;
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO time_accounting (
|
||||||
|
ticket_id, article_id, time_unit,
|
||||||
|
create_time, create_by, change_time, change_by
|
||||||
|
) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
|
||||||
|
[ticketId, articleId, parsedTime, operatorId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ message: 'Tempo aggiornato con successo!' });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error updating article time:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user