fix: correzione doppia consuntivazione note. fix: utenti ldap. feat: ricerca per customer useers. feat: possiblità di rimuovere le note
This commit is contained in:
@@ -30,6 +30,9 @@ LDAP_SYNC_INTERVAL_HOURS=24
|
|||||||
# Imposta a true per forzare l'aggiornamento diretto del DB per tutte le modifiche ai ticket bypassando l'API REST (esclusa la ricerca LDAP)
|
# Imposta a true per forzare l'aggiornamento diretto del DB per tutte le modifiche ai ticket bypassando l'API REST (esclusa la ricerca LDAP)
|
||||||
FORCE_DB_UPDATE=false
|
FORCE_DB_UPDATE=false
|
||||||
|
|
||||||
|
# Tempo di attesa in millisecondi prima della verifica a database dopo l'inserimento nota via API (default 3000)
|
||||||
|
OTRS_API_FALLBACK_WAIT_MS=3000
|
||||||
|
|
||||||
# Chiave per cifrare le frasi nel database locale (NON CANCELLARE O MODIFICARE SE CI SONO DATI CRIPTATI)
|
# Chiave per cifrare le frasi nel database locale (NON CANCELLARE O MODIFICARE SE CI SONO DATI CRIPTATI)
|
||||||
CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc
|
CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc
|
||||||
|
|
||||||
|
|||||||
@@ -1157,6 +1157,7 @@ body {
|
|||||||
.batch-bar {
|
.batch-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
|
|||||||
+75
-4
@@ -186,8 +186,8 @@ const App = {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
this.lookups = JSON.parse(cached);
|
this.lookups = JSON.parse(cached);
|
||||||
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined) {
|
if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined || !this.lookups.customerUsers || !this.lookups.customer_users_version_1) {
|
||||||
throw new Error('Outdated config cache (missing autoTimeMinHour)');
|
throw new Error('Outdated config cache (missing autoTimeMinHour or customerUsers)');
|
||||||
}
|
}
|
||||||
this.lookupsLoaded = true;
|
this.lookupsLoaded = true;
|
||||||
return;
|
return;
|
||||||
@@ -198,16 +198,17 @@ const App = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [queues, states, priorities, users, types, config] = await Promise.all([
|
const [queues, states, priorities, users, types, config, customerUsers] = await Promise.all([
|
||||||
this.api('/api/queues'),
|
this.api('/api/queues'),
|
||||||
this.api('/api/states'),
|
this.api('/api/states'),
|
||||||
this.api('/api/priorities'),
|
this.api('/api/priorities'),
|
||||||
this.api('/api/users'),
|
this.api('/api/users'),
|
||||||
this.api('/api/types'),
|
this.api('/api/types'),
|
||||||
this.api('/api/config').catch(() => ({ defaultAgentLogin: '' })),
|
this.api('/api/config').catch(() => ({ defaultAgentLogin: '' })),
|
||||||
|
this.api('/api/customer-users/search?q=').catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.lookups = { queues, states, priorities, users, types, config };
|
this.lookups = { queues, states, priorities, users, types, config, customerUsers, customer_users_version_1: true };
|
||||||
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
|
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
|
||||||
this.lookupsLoaded = true;
|
this.lookupsLoaded = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -576,6 +577,76 @@ const App = {
|
|||||||
console.warn('Failed to update sidebar badges:', e);
|
console.warn('Failed to update sidebar badges:', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Custom confirm dialog in the center of the screen */
|
||||||
|
confirm(title, message, options = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.position = 'fixed';
|
||||||
|
overlay.style.top = '0';
|
||||||
|
overlay.style.left = '0';
|
||||||
|
overlay.style.width = '100vw';
|
||||||
|
overlay.style.height = '100vh';
|
||||||
|
overlay.style.background = 'rgba(0, 0, 0, 0.6)';
|
||||||
|
overlay.style.backdropFilter = 'blur(4px)';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.style.alignItems = 'center';
|
||||||
|
overlay.style.justifyContent = 'center';
|
||||||
|
overlay.style.zIndex = '99999';
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
overlay.style.transition = 'opacity 0.2s ease';
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.style.background = 'var(--bg-card, #1e1e2e)';
|
||||||
|
card.style.border = '1px solid var(--border-subtle, #313244)';
|
||||||
|
card.style.borderRadius = 'var(--radius-lg, 12px)';
|
||||||
|
card.style.padding = 'var(--space-lg, 24px)';
|
||||||
|
card.style.width = '100%';
|
||||||
|
card.style.maxWidth = '400px';
|
||||||
|
card.style.boxShadow = 'var(--shadow-lg, 0 10px 30px rgba(0,0,0,0.5))';
|
||||||
|
card.style.transform = 'scale(0.9)';
|
||||||
|
card.style.transition = 'transform 0.2s ease';
|
||||||
|
card.className = 'confirm-dialog-card';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<h3 style="margin-top: 0; margin-bottom: var(--space-xs, 8px); color: var(--text-primary, #cdd6f4); font-size: 1.2rem; font-weight: 600;">${title}</h3>
|
||||||
|
<p style="margin-bottom: var(--space-lg, 24px); color: var(--text-muted, #a6adc8); font-size: 0.95rem; line-height: 1.5;">${message}</p>
|
||||||
|
<div style="display: flex; gap: var(--space-sm, 12px); justify-content: flex-end;">
|
||||||
|
<button id="confirm-btn-cancel" class="btn btn-ghost" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px);">${options.cancelText || 'Annulla'}</button>
|
||||||
|
<button id="confirm-btn-ok" class="btn btn-danger" style="height: 36px; font-size: 0.9rem; padding: 0 16px; border-radius: var(--radius-md, 6px); font-weight: 500; cursor: pointer;">${options.confirmText || 'Conferma'}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
overlay.appendChild(card);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Trigger animations
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.style.opacity = '1';
|
||||||
|
card.style.transform = 'scale(1)';
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanUp = (result) => {
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
card.style.transform = 'scale(0.9)';
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(result);
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnCancel = card.querySelector('#confirm-btn-cancel');
|
||||||
|
const btnOk = card.querySelector('#confirm-btn-ok');
|
||||||
|
|
||||||
|
btnCancel.addEventListener('click', () => cleanUp(false));
|
||||||
|
btnOk.addEventListener('click', () => cleanUp(true));
|
||||||
|
|
||||||
|
// Close on backdrop click
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) cleanUp(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start the app when DOM is ready
|
// Start the app when DOM is ready
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const Filters = {
|
|||||||
state_id: '',
|
state_id: '',
|
||||||
priority_id: '',
|
priority_id: '',
|
||||||
user_id: '',
|
user_id: '',
|
||||||
|
customer_user_id: '',
|
||||||
date_from: '',
|
date_from: '',
|
||||||
date_to: '',
|
date_to: '',
|
||||||
},
|
},
|
||||||
@@ -19,6 +20,7 @@ const Filters = {
|
|||||||
state_id: '',
|
state_id: '',
|
||||||
priority_id: '',
|
priority_id: '',
|
||||||
user_id: '',
|
user_id: '',
|
||||||
|
customer_user_id: '',
|
||||||
date_from: '',
|
date_from: '',
|
||||||
date_to: '',
|
date_to: '',
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,7 @@ const Filters = {
|
|||||||
|
|
||||||
/** 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: '', customer_user_id: '', date_from: '', date_to: '' };
|
||||||
if (this.currentMode === 'my') {
|
if (this.currentMode === 'my') {
|
||||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||||
this.state.user_id = activeAgentId;
|
this.state.user_id = activeAgentId;
|
||||||
@@ -107,6 +109,36 @@ const Filters = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Helper to compute trigger button label text for customer users */
|
||||||
|
getCustomerUserMultiselectLabel(lookups) {
|
||||||
|
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||||
|
? this.state.customer_user_id
|
||||||
|
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||||
|
|
||||||
|
if (selectedList.length === 0) {
|
||||||
|
return 'Tutti';
|
||||||
|
}
|
||||||
|
const selectedNames = (lookups.customerUsers || [])
|
||||||
|
.filter(u => selectedList.includes(String(u.login)))
|
||||||
|
.map(u => `${u.last_name} ${u.first_name}`);
|
||||||
|
|
||||||
|
if (selectedNames.length === (lookups.customerUsers || []).length) {
|
||||||
|
return 'Tutti';
|
||||||
|
} else if (selectedNames.length <= 2) {
|
||||||
|
return selectedNames.join(', ');
|
||||||
|
} else {
|
||||||
|
return `${selectedNames.length} selezionati`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update customer user label DOM element dynamically */
|
||||||
|
updateCustomerUserMultiselectLabel(lookups) {
|
||||||
|
const labelEl = document.getElementById('customer-user-multiselect-label');
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = this.getCustomerUserMultiselectLabel(lookups);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render filter bar HTML.
|
* Render filter bar HTML.
|
||||||
* @param {Object} lookups - { queues, states, priorities, users }
|
* @param {Object} lookups - { queues, states, priorities, users }
|
||||||
@@ -123,6 +155,7 @@ const Filters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentLabel = this.getStateMultiselectLabel(lookups);
|
const currentLabel = this.getStateMultiselectLabel(lookups);
|
||||||
|
const customerUserLabel = this.getCustomerUserMultiselectLabel(lookups);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="filters-bar" id="filters-bar">
|
<div class="filters-bar" id="filters-bar">
|
||||||
@@ -174,6 +207,23 @@ const Filters = {
|
|||||||
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group" style="position:relative;">
|
||||||
|
<span class="filter-label">Utente Cliente</span>
|
||||||
|
<div class="multiselect-dropdown" id="customer-user-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;">
|
||||||
|
<span class="multiselect-label" id="customer-user-multiselect-label" style="text-overflow:ellipsis; overflow:hidden; white-space:nowrap; max-width:180px;">${App.escapeHtml(customerUserLabel)}</span>
|
||||||
|
<span style="font-size:0.6rem; color:var(--text-muted); margin-left:6px;">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="multiselect-popover" id="customer-user-multiselect-popover" style="display:none; position:absolute; left:0; top:100%; width: 280px; 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;">
|
||||||
|
<input type="text" id="customer-user-search-input" placeholder="Cerca utente..." style="width:100%; padding:6px 8px; font-size:0.8rem; background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); margin-bottom:6px; box-sizing:border-box; color:var(--text-primary); font-family:inherit;" autocomplete="off" />
|
||||||
|
<div id="customer-user-items-container" style="display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; padding-bottom:6px; border-bottom:1px solid var(--border-subtle);">
|
||||||
|
<!-- Populated dynamically -->
|
||||||
|
</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="customer-user-multiselect-clear" style="height:20px; padding:0 8px; font-size:0.75rem;">Reset</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-xs" id="customer-user-multiselect-ok" style="height:20px; padding:0 8px; font-size:0.75rem; line-height:20px;">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span class="filter-label">Da Data/Ora</span>
|
<span class="filter-label">Da Data/Ora</span>
|
||||||
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
<input type="datetime-local" class="filter-select" data-filter="date_from" id="filter-date-from" value="${this.state.date_from || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||||
@@ -265,6 +315,117 @@ const Filters = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customer User Multiselect Popover binding
|
||||||
|
const cuDropdown = document.getElementById('customer-user-multiselect-dropdown');
|
||||||
|
const cuPopover = document.getElementById('customer-user-multiselect-popover');
|
||||||
|
const cuSearchInput = document.getElementById('customer-user-search-input');
|
||||||
|
const cuItemsContainer = document.getElementById('customer-user-items-container');
|
||||||
|
const cuOkBtn = document.getElementById('customer-user-multiselect-ok');
|
||||||
|
const cuClearBtn = document.getElementById('customer-user-multiselect-clear');
|
||||||
|
|
||||||
|
const renderCustomerUserItems = () => {
|
||||||
|
if (!cuItemsContainer) return;
|
||||||
|
const q = (cuSearchInput ? cuSearchInput.value : '').toLowerCase().trim();
|
||||||
|
const selectedList = Array.isArray(this.state.customer_user_id)
|
||||||
|
? this.state.customer_user_id
|
||||||
|
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||||
|
|
||||||
|
const filtered = (App.lookups.customerUsers || []).filter(u => {
|
||||||
|
const fullName = `${u.last_name} ${u.first_name} (${u.login})`.toLowerCase();
|
||||||
|
return fullName.includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
cuItemsContainer.innerHTML = filtered.map(u => {
|
||||||
|
const isSelected = selectedList.includes(String(u.login));
|
||||||
|
const activeStyle = isSelected ? 'background: var(--accent-primary); color: #fff;' : '';
|
||||||
|
return `
|
||||||
|
<div class="customer-user-multiselect-item ${isSelected ? 'active' : ''}" data-value="${u.login}" 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(u.last_name)} ${App.escapeHtml(u.first_name)} <span style="font-size:0.75rem;opacity:0.8;">(${App.escapeHtml(u.login)})</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Bind clicks to items
|
||||||
|
cuItemsContainer.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const login = item.dataset.value;
|
||||||
|
const currentSelected = Array.isArray(this.state.customer_user_id)
|
||||||
|
? [...this.state.customer_user_id]
|
||||||
|
: (typeof this.state.customer_user_id === 'string' && this.state.customer_user_id ? this.state.customer_user_id.split(',') : []);
|
||||||
|
|
||||||
|
const idx = currentSelected.indexOf(login);
|
||||||
|
if (idx > -1) {
|
||||||
|
currentSelected.splice(idx, 1);
|
||||||
|
item.classList.remove('active');
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
} else {
|
||||||
|
currentSelected.push(login);
|
||||||
|
item.classList.add('active');
|
||||||
|
item.style.background = 'var(--accent-primary)';
|
||||||
|
item.style.color = '#fff';
|
||||||
|
}
|
||||||
|
this.state.customer_user_id = currentSelected.join(',');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cuDropdown && cuPopover) {
|
||||||
|
cuDropdown.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Close other popovers
|
||||||
|
const statePopover = document.getElementById('state-multiselect-popover');
|
||||||
|
if (statePopover) statePopover.style.display = 'none';
|
||||||
|
|
||||||
|
const isOpen = cuPopover.style.display === 'block';
|
||||||
|
cuPopover.style.display = isOpen ? 'none' : 'block';
|
||||||
|
if (!isOpen) {
|
||||||
|
if (cuSearchInput) {
|
||||||
|
cuSearchInput.value = '';
|
||||||
|
}
|
||||||
|
renderCustomerUserItems();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (cuSearchInput) cuSearchInput.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cuPopover.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
cuPopover.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cuSearchInput) {
|
||||||
|
cuSearchInput.addEventListener('input', () => {
|
||||||
|
renderCustomerUserItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cuOkBtn) {
|
||||||
|
cuOkBtn.addEventListener('click', () => {
|
||||||
|
this.save();
|
||||||
|
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||||
|
if (cuPopover) cuPopover.style.display = 'none';
|
||||||
|
if (onFilterChange) onFilterChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cuClearBtn) {
|
||||||
|
cuClearBtn.addEventListener('click', () => {
|
||||||
|
this.state.customer_user_id = '';
|
||||||
|
this.save();
|
||||||
|
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||||
|
if (cuSearchInput) cuSearchInput.value = '';
|
||||||
|
renderCustomerUserItems();
|
||||||
|
if (cuPopover) cuPopover.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', () => {
|
||||||
@@ -282,7 +443,7 @@ const Filters = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also clear multiselect items and label
|
// Also clear state multiselect items and label
|
||||||
const statePopover = document.getElementById('state-multiselect-popover');
|
const statePopover = document.getElementById('state-multiselect-popover');
|
||||||
if (statePopover) {
|
if (statePopover) {
|
||||||
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
statePopover.querySelectorAll('.state-multiselect-item').forEach(item => {
|
||||||
@@ -293,6 +454,19 @@ const Filters = {
|
|||||||
}
|
}
|
||||||
this.updateStateMultiselectLabel(App.lookups);
|
this.updateStateMultiselectLabel(App.lookups);
|
||||||
|
|
||||||
|
// Also clear customer user multiselect items and label
|
||||||
|
const cuPopover = document.getElementById('customer-user-multiselect-popover');
|
||||||
|
if (cuPopover) {
|
||||||
|
const searchInput = cuPopover.querySelector('#customer-user-search-input');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
cuPopover.querySelectorAll('.customer-user-multiselect-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
item.style.background = '';
|
||||||
|
item.style.color = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.updateCustomerUserMultiselectLabel(App.lookups);
|
||||||
|
|
||||||
if (onFilterChange) onFilterChange();
|
if (onFilterChange) onFilterChange();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,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 && att.filename !== 'file-1');
|
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1' && att.filename !== 'file-2');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
||||||
@@ -218,6 +218,7 @@ const TicketDetailView = {
|
|||||||
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
|
<span style="font-size: 0.72rem; color: var(--text-muted); font-family: monospace; margin-right: var(--space-xs);">ID: ${a.article_id}</span>
|
||||||
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
<span class="article-from">${App.escapeHtml(a.a_from || a.creator_first + ' ' + a.creator_last || 'Sistema')}</span>
|
||||||
${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>` : ''}
|
||||||
|
<button class="btn-delete-article" data-article-id="${a.article_id}" style="background:none; border:none; cursor:pointer; font-size:0.85rem; padding: 2px; margin-left: var(--space-xs); display:inline-flex; align-items:center; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity=1" onmouseout="this.style.opacity=0.6" title="Elimina Articolo">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||||
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
|
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
|
||||||
@@ -827,6 +828,32 @@ const TicketDetailView = {
|
|||||||
p.style.display = 'none';
|
p.style.display = 'none';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Delete Article Event Listeners
|
||||||
|
document.querySelectorAll('.btn-delete-article').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const articleId = btn.dataset.articleId;
|
||||||
|
const ok = await App.confirm(
|
||||||
|
"Elimina Articolo",
|
||||||
|
"Sei sicuro di voler eliminare questa nota/articolo? Tutti i file allegati e i tempi ad esso associati verranno rimossi permanentemente.",
|
||||||
|
{ confirmText: 'Elimina', cancelText: 'Annulla' }
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
try {
|
||||||
|
btn.disabled = true;
|
||||||
|
await App.api(`/api/tickets/articles/${articleId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
Toast.success('Articolo/nota eliminato con successo!');
|
||||||
|
this.render(this.ticketId);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error('Errore durante l\'eliminazione: ' + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// HTML Mode Toggle Listener
|
// HTML Mode Toggle Listener
|
||||||
const htmlToggle = document.getElementById('html-toggle');
|
const htmlToggle = document.getElementById('html-toggle');
|
||||||
if (htmlToggle) {
|
if (htmlToggle) {
|
||||||
|
|||||||
+168
-23
@@ -192,30 +192,89 @@ router.get('/lock-types', async (req, res) => {
|
|||||||
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) {
|
// 1. Fetch from local SQLite LDAP cache
|
||||||
result = await pool.query(
|
let localRows = [];
|
||||||
`SELECT customer_id, name
|
try {
|
||||||
FROM customer_company
|
if (!q) {
|
||||||
WHERE valid_id = 1
|
localRows = db.prepare(`
|
||||||
ORDER BY name
|
SELECT DISTINCT customer_id AS customer_id, customer_id AS name
|
||||||
LIMIT 20`
|
FROM customer_user_cache
|
||||||
);
|
WHERE customer_id IS NOT NULL AND customer_id != ''
|
||||||
} else {
|
ORDER BY customer_id
|
||||||
const searchTerm = `%${q}%`;
|
LIMIT 500
|
||||||
result = await pool.query(
|
`).all();
|
||||||
`SELECT customer_id, name
|
} else {
|
||||||
FROM customer_company
|
const searchTerm = `%${q}%`;
|
||||||
WHERE valid_id = 1 AND (
|
localRows = db.prepare(`
|
||||||
customer_id ILIKE $1 OR
|
SELECT DISTINCT customer_id AS customer_id, customer_id AS name
|
||||||
name ILIKE $1
|
FROM customer_user_cache
|
||||||
)
|
WHERE customer_id IS NOT NULL AND customer_id != '' AND customer_id LIKE ?
|
||||||
ORDER BY name
|
ORDER BY customer_id
|
||||||
LIMIT 20`,
|
LIMIT 500
|
||||||
[searchTerm]
|
`).all(searchTerm);
|
||||||
);
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to query local customer cache:', e.message);
|
||||||
}
|
}
|
||||||
res.json(result.rows);
|
|
||||||
|
// 2. Fetch from OTRS Postgres DB
|
||||||
|
let dbRows = [];
|
||||||
|
try {
|
||||||
|
if (!q) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT customer_id, name
|
||||||
|
FROM customer_company
|
||||||
|
WHERE valid_id = 1
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 500`
|
||||||
|
);
|
||||||
|
dbRows = result.rows;
|
||||||
|
} else {
|
||||||
|
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 500`,
|
||||||
|
[searchTerm]
|
||||||
|
);
|
||||||
|
dbRows = result.rows;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to query OTRS customer_company table:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Merge results and remove duplicates by customer_id
|
||||||
|
const seen = new Set();
|
||||||
|
const merged = [];
|
||||||
|
|
||||||
|
// Prioritize OTRS database rows (which might have better names)
|
||||||
|
for (const row of dbRows) {
|
||||||
|
const cid = String(row.customer_id).trim();
|
||||||
|
if (cid && !seen.has(cid.toLowerCase())) {
|
||||||
|
seen.add(cid.toLowerCase());
|
||||||
|
merged.push({ customer_id: cid, name: row.name || cid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add local LDAP rows
|
||||||
|
for (const row of localRows) {
|
||||||
|
const cid = String(row.customer_id).trim();
|
||||||
|
if (cid && !seen.has(cid.toLowerCase())) {
|
||||||
|
seen.add(cid.toLowerCase());
|
||||||
|
merged.push({ customer_id: cid, name: row.name || cid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by name
|
||||||
|
merged.sort((a, b) => a.name.localeCompare(b.name, 'it', { sensitivity: 'base' }));
|
||||||
|
|
||||||
|
res.json(merged.slice(0, 500));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error searching customer companies:', err);
|
console.error('Error searching customer companies:', err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -226,6 +285,92 @@ router.get('/customer-companies/search', async (req, res) => {
|
|||||||
router.get('/customer-users/search', async (req, res) => {
|
router.get('/customer-users/search', async (req, res) => {
|
||||||
const { q = '', customer_company_id } = req.query;
|
const { q = '', customer_company_id } = req.query;
|
||||||
|
|
||||||
|
// If q is empty, we return a merged list for populating filter dropdowns
|
||||||
|
if (!q) {
|
||||||
|
let localRows = [];
|
||||||
|
try {
|
||||||
|
if (customer_company_id) {
|
||||||
|
localRows = db.prepare(`
|
||||||
|
SELECT login, email, first_name, last_name, customer_id
|
||||||
|
FROM customer_user_cache
|
||||||
|
WHERE customer_id = ?
|
||||||
|
ORDER BY last_name, first_name
|
||||||
|
LIMIT 1000
|
||||||
|
`).all(customer_company_id);
|
||||||
|
} else {
|
||||||
|
localRows = db.prepare(`
|
||||||
|
SELECT login, email, first_name, last_name, customer_id
|
||||||
|
FROM customer_user_cache
|
||||||
|
ORDER BY last_name, first_name
|
||||||
|
LIMIT 1000
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to query local customer user cache:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbRows = [];
|
||||||
|
try {
|
||||||
|
let queryText = `
|
||||||
|
SELECT login, email, first_name, last_name, customer_id
|
||||||
|
FROM customer_user
|
||||||
|
WHERE valid_id = 1
|
||||||
|
`;
|
||||||
|
let queryParams = [];
|
||||||
|
if (customer_company_id) {
|
||||||
|
queryText += ` AND customer_id = $1`;
|
||||||
|
queryParams.push(customer_company_id);
|
||||||
|
}
|
||||||
|
queryText += ` ORDER BY last_name, first_name LIMIT 1000`;
|
||||||
|
|
||||||
|
const result = await pool.query(queryText, queryParams);
|
||||||
|
dbRows = result.rows;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to query OTRS customer_user table:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and deduplicate by login
|
||||||
|
const seen = new Set();
|
||||||
|
const merged = [];
|
||||||
|
|
||||||
|
for (const row of dbRows) {
|
||||||
|
const login = String(row.login).trim();
|
||||||
|
if (login && !seen.has(login.toLowerCase())) {
|
||||||
|
seen.add(login.toLowerCase());
|
||||||
|
merged.push({
|
||||||
|
login,
|
||||||
|
email: row.email || '',
|
||||||
|
first_name: row.first_name || '',
|
||||||
|
last_name: row.last_name || '',
|
||||||
|
customer_id: row.customer_id || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of localRows) {
|
||||||
|
const login = String(row.login).trim();
|
||||||
|
if (login && !seen.has(login.toLowerCase())) {
|
||||||
|
seen.add(login.toLowerCase());
|
||||||
|
merged.push({
|
||||||
|
login,
|
||||||
|
email: row.email || '',
|
||||||
|
first_name: row.first_name || '',
|
||||||
|
last_name: row.last_name || '',
|
||||||
|
customer_id: row.customer_id || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort alphabetically by last name, first name
|
||||||
|
merged.sort((a, b) => {
|
||||||
|
const nameA = `${a.last_name} ${a.first_name}`.trim();
|
||||||
|
const nameB = `${b.last_name} ${b.first_name}`.trim();
|
||||||
|
return nameA.localeCompare(nameB, 'it', { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(merged.slice(0, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Try to search via OTRS GenericInterface REST API if configured
|
// 1. Try to search via OTRS GenericInterface 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) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+210
-42
@@ -19,6 +19,24 @@ async function resolveAgentName(agentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Helper: resolve agent details for a_from header (best-effort, fallback to 'OTRS Turbo Agent')
|
||||||
|
async function resolveAgentFromHeader(agentId, client = pool) {
|
||||||
|
try {
|
||||||
|
const r = await client.query(
|
||||||
|
`SELECT first_name, last_name, email, login FROM users WHERE id = $1`,
|
||||||
|
[agentId]
|
||||||
|
);
|
||||||
|
if (r.rows.length > 0) {
|
||||||
|
const u = r.rows[0];
|
||||||
|
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login || 'Agent';
|
||||||
|
const email = u.email || 'agent@localhost';
|
||||||
|
return `"${fullName}" <${email}>`;
|
||||||
|
}
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
return 'OTRS Turbo Agent';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Helper for OTRS CE GenericInterface REST API calls
|
// Helper for OTRS CE GenericInterface REST API calls
|
||||||
async function otrsRequest(method, path, bodyData = {}) {
|
async function otrsRequest(method, path, bodyData = {}) {
|
||||||
const OTRS_API_USER = process.env.OTRS_API_USER;
|
const OTRS_API_USER = process.env.OTRS_API_USER;
|
||||||
@@ -72,7 +90,7 @@ async function otrsRequest(method, path, bodyData = {}) {
|
|||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
queue_id, state_id, priority_id, user_id, type_id,
|
queue_id, state_id, priority_id, user_id, type_id, customer_user_id,
|
||||||
search, sort_by = 'create_time', sort_dir = 'DESC',
|
search, sort_by = 'create_time', sort_dir = 'DESC',
|
||||||
page = 1, per_page = 50,
|
page = 1, per_page = 50,
|
||||||
date_from, date_to
|
date_from, date_to
|
||||||
@@ -82,6 +100,23 @@ router.get('/', async (req, res) => {
|
|||||||
const params = [];
|
const params = [];
|
||||||
let paramIdx = 1;
|
let paramIdx = 1;
|
||||||
|
|
||||||
|
if (customer_user_id) {
|
||||||
|
let logins = [];
|
||||||
|
if (Array.isArray(customer_user_id)) {
|
||||||
|
logins = customer_user_id.map(l => String(l).trim()).filter(Boolean);
|
||||||
|
} else if (typeof customer_user_id === 'string') {
|
||||||
|
logins = customer_user_id.split(',').map(l => l.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
logins = [String(customer_user_id).trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logins.length > 0) {
|
||||||
|
const placeholders = logins.map(() => `$${paramIdx++}`).join(', ');
|
||||||
|
conditions.push(`t.customer_user_id IN (${placeholders})`);
|
||||||
|
params.push(...logins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (queue_id) {
|
if (queue_id) {
|
||||||
conditions.push(`t.queue_id = $${paramIdx++}`);
|
conditions.push(`t.queue_id = $${paramIdx++}`);
|
||||||
params.push(parseInt(queue_id));
|
params.push(parseInt(queue_id));
|
||||||
@@ -336,7 +371,11 @@ router.get('/:id', async (req, res) => {
|
|||||||
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||||
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||||
LEFT JOIN users creator ON a.create_by = creator.id
|
LEFT JOIN users creator ON a.create_by = creator.id
|
||||||
LEFT JOIN time_accounting ta ON a.id = ta.article_id
|
LEFT JOIN (
|
||||||
|
SELECT article_id, SUM(time_unit) AS time_unit
|
||||||
|
FROM time_accounting
|
||||||
|
GROUP BY article_id
|
||||||
|
) ta ON a.id = ta.article_id
|
||||||
WHERE a.ticket_id = $1
|
WHERE a.ticket_id = $1
|
||||||
ORDER BY a.create_time DESC, a.id DESC`,
|
ORDER BY a.create_time DESC, a.id DESC`,
|
||||||
[id]
|
[id]
|
||||||
@@ -725,22 +764,6 @@ router.patch('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const result = await otrsRequest('PATCH', `/Ticket/${id}`, reqBody);
|
const result = await otrsRequest('PATCH', `/Ticket/${id}`, reqBody);
|
||||||
|
|
||||||
// Option 1: Log the time directly to the DB if the API succeeded but OTRS didn't save it
|
|
||||||
if (!isNaN(timeUnit) && timeUnit > 0 && result && result.ArticleID) {
|
|
||||||
try {
|
|
||||||
await pool.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, result.ArticleID, timeUnit, operatorId]
|
|
||||||
);
|
|
||||||
console.log(`[Time Accounting] Successfully logged ${timeUnit} minutes for ticket ${id} via DB insert.`);
|
|
||||||
} catch (timeDbErr) {
|
|
||||||
console.error('[Time Accounting] Failed to log time unit in database:', timeDbErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isClosing = updates.ticket_state_id && current.ticket_state_id !== updates.ticket_state_id;
|
const isClosing = updates.ticket_state_id && current.ticket_state_id !== updates.ticket_state_id;
|
||||||
resolveAgentName(operatorId).then(agente_nome => {
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
logAttivita({
|
logAttivita({
|
||||||
@@ -911,6 +934,8 @@ router.patch('/:id', async (req, res) => {
|
|||||||
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
const contentSize = Buffer.byteLength(htmlBody, 'utf-8');
|
||||||
const base64Body = binaryBody.toString('base64');
|
const base64Body = binaryBody.toString('base64');
|
||||||
|
|
||||||
|
const fromHeader = await resolveAgentFromHeader(operatorId, client);
|
||||||
|
|
||||||
// Create article_data_mime
|
// Create article_data_mime
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO article_data_mime (
|
`INSERT INTO article_data_mime (
|
||||||
@@ -924,7 +949,7 @@ router.patch('/:id', async (req, res) => {
|
|||||||
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
||||||
NOW(), $6, NOW(), $6
|
NOW(), $6, NOW(), $6
|
||||||
)`,
|
)`,
|
||||||
[articleId, 'OTRS Turbo Agent', 'Consuntivazione', htmlBody, contentPath, operatorId]
|
[articleId, fromHeader, 'Consuntivazione', htmlBody, contentPath, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create article_data_mime_attachment for OTRS CE HTML rendering
|
// Create article_data_mime_attachment for OTRS CE HTML rendering
|
||||||
@@ -995,7 +1020,11 @@ router.get('/:id/articles', async (req, res) => {
|
|||||||
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||||
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||||
LEFT JOIN users creator ON a.create_by = creator.id
|
LEFT JOIN users creator ON a.create_by = creator.id
|
||||||
LEFT JOIN time_accounting ta ON a.id = ta.article_id
|
LEFT JOIN (
|
||||||
|
SELECT article_id, SUM(time_unit) AS time_unit
|
||||||
|
FROM time_accounting
|
||||||
|
GROUP BY article_id
|
||||||
|
) ta ON a.id = ta.article_id
|
||||||
WHERE a.ticket_id = $1
|
WHERE a.ticket_id = $1
|
||||||
ORDER BY a.create_time DESC, a.id DESC`,
|
ORDER BY a.create_time DESC, a.id DESC`,
|
||||||
[id]
|
[id]
|
||||||
@@ -1047,21 +1076,7 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
|
|
||||||
const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload);
|
const result = await otrsRequest('PATCH', `/Ticket/${id}`, payload);
|
||||||
|
|
||||||
// Option 1: Log the time directly to the DB if the API succeeded but OTRS didn't save it
|
|
||||||
if (time_unit && result && result.ArticleID) {
|
|
||||||
try {
|
|
||||||
await pool.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, result.ArticleID, parseFloat(time_unit), operatorId]
|
|
||||||
);
|
|
||||||
console.log(`[Time Accounting] Successfully logged ${time_unit} minutes for ticket ${id} via DB insert.`);
|
|
||||||
} catch (timeDbErr) {
|
|
||||||
console.error('[Time Accounting] Failed to log time unit in database:', timeDbErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveAgentName(operatorId).then(agente_nome => {
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
logAttivita({
|
logAttivita({
|
||||||
@@ -1072,14 +1087,24 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
esito: 'successo',
|
esito: 'successo',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: 'Nota aggiunta! (via API REST)',
|
message: 'Nota aggiunta! (via API REST)',
|
||||||
article_id: result.ArticleID,
|
article_id: result.ArticleID,
|
||||||
result
|
result
|
||||||
});
|
});
|
||||||
} catch (restErr) {
|
} catch (restErr) {
|
||||||
console.warn('Failed to add article via REST API, falling back to database insert:', restErr.message);
|
console.error('Failed to add article via REST API:', restErr.message);
|
||||||
// Fall through to standard direct database update below
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome,
|
||||||
|
titolo_azione: 'Aggiunta Nota',
|
||||||
|
azione: { ticket_id: id, error: restErr.message },
|
||||||
|
esito: 'errore'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return res.status(500).json({ error: 'Errore durante l\'aggiunta della nota tramite API: ' + restErr.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,6 +1115,62 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
const { subject, body, is_visible_for_customer = 0, time_unit, attachments } = 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;
|
||||||
|
|
||||||
|
// Check if this article was already inserted (e.g., via REST API or concurrent request)
|
||||||
|
const thirtySecondsAgo = new Date(Date.now() - 30000);
|
||||||
|
const recentCheck = await client.query(
|
||||||
|
`SELECT a.id
|
||||||
|
FROM article a
|
||||||
|
JOIN article_data_mime adm ON a.id = adm.article_id
|
||||||
|
WHERE a.ticket_id = $1
|
||||||
|
AND adm.a_subject = $2
|
||||||
|
AND adm.a_body = $3
|
||||||
|
AND a.create_time >= $4`,
|
||||||
|
[id, subject || 'Nota interna', body, thirtySecondsAgo]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recentCheck.rows.length > 0) {
|
||||||
|
const existingArticleId = recentCheck.rows[0].id;
|
||||||
|
console.log(`[Fallback Check] Found existing recent article (ID: ${existingArticleId}) in database. Skipping duplicate insert.`);
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
// If time_unit is provided, insert into time_accounting if not already present
|
||||||
|
if (time_unit !== undefined && time_unit !== null && time_unit !== '') {
|
||||||
|
const parsedTime = parseFloat(time_unit);
|
||||||
|
if (!isNaN(parsedTime) && parsedTime > 0) {
|
||||||
|
const timeCheck = await client.query(
|
||||||
|
`SELECT id FROM time_accounting WHERE ticket_id = $1 AND article_id = $2`,
|
||||||
|
[id, existingArticleId]
|
||||||
|
);
|
||||||
|
if (timeCheck.rows.length === 0) {
|
||||||
|
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, existingArticleId, parsedTime, operatorId]
|
||||||
|
);
|
||||||
|
console.log(`[Fallback Check] Logged missing time accounting (${parsedTime} min) for existing article.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome,
|
||||||
|
titolo_azione: 'Aggiunta Nota',
|
||||||
|
azione: { ticket_id: id, subject, time_unit, has_attachments: !!(attachments && attachments.length) },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
article_id: existingArticleId,
|
||||||
|
message: 'Nota aggiunta! (rilevata in DB)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Verify ticket exists
|
// Verify ticket exists
|
||||||
@@ -1129,6 +1210,8 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
|
const contentPath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const fromHeader = await resolveAgentFromHeader(operatorId, client);
|
||||||
|
|
||||||
// Create article_data_mime
|
// Create article_data_mime
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO article_data_mime (
|
`INSERT INTO article_data_mime (
|
||||||
@@ -1142,7 +1225,7 @@ router.post('/:id/articles', async (req, res) => {
|
|||||||
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
|
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
|
||||||
NOW(), $7, NOW(), $7
|
NOW(), $7, NOW(), $7
|
||||||
)`,
|
)`,
|
||||||
[articleId, 'OTRS Turbo Agent', subject || 'Nota interna', body, contentType, contentPath, operatorId]
|
[articleId, fromHeader, subject || 'Nota interna', body, contentType, contentPath, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and base64 encoded text)
|
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and base64 encoded text)
|
||||||
@@ -1433,6 +1516,90 @@ router.post('/articles/:articleId/retrodata-article', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DELETE /api/tickets/articles/:articleId — Delete an article/note
|
||||||
|
router.delete('/articles/:articleId', async (req, res) => {
|
||||||
|
const { articleId } = req.params;
|
||||||
|
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// 1. Set article_id in ticket_history to NULL to avoid constraint violation
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket_history SET article_id = NULL WHERE article_id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete flags from article_flag
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM article_flag WHERE article_id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete search index entries from article_search_index
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM article_search_index WHERE article_id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Delete time accounting entries
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM time_accounting WHERE article_id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Delete attachments
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM article_data_mime_attachment WHERE article_id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Delete mime data
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM article_data_mime WHERE article_id = $1`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Delete article itself
|
||||||
|
const deleteRes = await client.query(
|
||||||
|
`DELETE FROM article WHERE id = $1 RETURNING ticket_id`,
|
||||||
|
[articleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ticketId = deleteRes.rows.length > 0 ? deleteRes.rows[0].ticket_id : null;
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome,
|
||||||
|
titolo_azione: 'Eliminazione Articolo',
|
||||||
|
azione: { article_id: articleId, ticket_id: ticketId },
|
||||||
|
esito: 'successo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Articolo eliminato con successo!' });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Error deleting article:', err);
|
||||||
|
resolveAgentName(operatorId).then(agente_nome => {
|
||||||
|
logAttivita({
|
||||||
|
agente_id: operatorId,
|
||||||
|
agente_nome,
|
||||||
|
titolo_azione: 'Eliminazione Articolo',
|
||||||
|
azione: { article_id: articleId, error: err.message },
|
||||||
|
esito: 'errore',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/tickets/attachments/:id — Download/View attachment
|
// GET /api/tickets/attachments/:id — Download/View attachment
|
||||||
router.get('/attachments/:id', async (req, res) => {
|
router.get('/attachments/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1902,6 +2069,7 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
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');
|
const base64Body = binaryBody.toString('base64');
|
||||||
|
const fromHeader = await resolveAgentFromHeader(operatorId, client);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO article_data_mime (
|
`INSERT INTO article_data_mime (
|
||||||
@@ -1909,11 +2077,11 @@ router.post('/auto-time', async (req, res) => {
|
|||||||
a_content_type, incoming_time, content_path,
|
a_content_type, incoming_time, content_path,
|
||||||
create_time, create_by, change_time, change_by
|
create_time, create_by, change_time, change_by
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, 'OTRS Turbo Agent', '', $2, $3,
|
$1, $2, '', $3, $4,
|
||||||
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $4,
|
'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $5,
|
||||||
NOW(), $5, NOW(), $5
|
NOW(), $6, NOW(), $6
|
||||||
)`,
|
)`,
|
||||||
[articleId, subject, htmlBody, contentPath, operatorId]
|
[articleId, fromHeader, subject, htmlBody, contentPath, operatorId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create article attachment (file-1) for OTRS CE HTML display
|
// Create article attachment (file-1) for OTRS CE HTML display
|
||||||
|
|||||||
Reference in New Issue
Block a user