Compare commits
2
Commits
ce904756dd
...
abb72d53c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb72d53c9 | ||
|
|
0b6907969d |
No files matched your search
@@ -1232,6 +1232,31 @@ body {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
|
||||
.batch-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.batch-bar-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.batch-bar-right .btn svg {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.batch-bar-right .btn:not(:disabled):hover svg {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ---- Pagination ---- */
|
||||
.pagination {
|
||||
display: flex;
|
||||
|
||||
@@ -96,53 +96,80 @@ const RicercaSpudorataView = {
|
||||
</div>
|
||||
|
||||
<!-- Batch Actions Bar -->
|
||||
<div class="batch-bar" id="batch-bar">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Stato</span>
|
||||
<select class="filter-select" id="batch-state">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Coda</span>
|
||||
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:180px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
<input type="hidden" id="batch-queue" />
|
||||
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; min-width: 480px; width: max-content; max-width: 85vw; z-index: 1001;"></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Owner</span>
|
||||
<select class="filter-select" id="batch-owner">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Responsabile</span>
|
||||
<select class="filter-select" id="batch-responsible">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
${(App.lookups.types || []).length > 0 ? `
|
||||
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
|
||||
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Tipo</span>
|
||||
<select class="filter-select" id="batch-type">
|
||||
<span class="filter-label">Stato</span>
|
||||
<select class="filter-select" id="batch-state">
|
||||
<option value="">—</option>
|
||||
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
` : ''}
|
||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Coda</span>
|
||||
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:180px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
<input type="hidden" id="batch-queue" />
|
||||
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; min-width: 480px; width: max-content; max-width: 85vw; z-index: 1001;"></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Owner</span>
|
||||
<select class="filter-select" id="batch-owner">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Responsabile</span>
|
||||
<select class="filter-select" id="batch-responsible">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
${(App.lookups.types || []).length > 0 ? `
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Tipo</span>
|
||||
<select class="filter-select" id="batch-type">
|
||||
<option value="">—</option>
|
||||
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Colonna Destra: Pulsanti delle Azioni -->
|
||||
<div class="batch-bar-right" style="display: flex; flex-direction: column; align-items: flex-end; gap: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled title="Aggiungi a gruppo" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled title="Unisci Selezionati" style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<circle cx="18" cy="18" r="3"></circle>
|
||||
<circle cx="6" cy="6" r="3"></circle>
|
||||
<path d="M6 21V9a9 9 0 0 0 9 9"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled title="Apri ticket in schede" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<path d="M2 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9z"></path>
|
||||
<path d="M6 3h12a2 2 0 0 1 2 2v12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
|
||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns">Copia numeri</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel" title="Annulla selezione">Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Bar (Operates 100% Client-Side in Memory) -->
|
||||
|
||||
@@ -12,7 +12,8 @@ const TicketDetailView = {
|
||||
if (!this.ticketId) return;
|
||||
const body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
|
||||
const subject = document.getElementById('note-subject') ? document.getElementById('note-subject').value.trim() : '';
|
||||
const time_unit = document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '';
|
||||
const time_unit = (document.getElementById('note-time-units') ? document.getElementById('note-time-units').value.trim() : '') ||
|
||||
(document.getElementById('note-time-units-bottom') ? document.getElementById('note-time-units-bottom').value.trim() : '');
|
||||
const ticket_state_id = document.getElementById('note-ticket-state') ? document.getElementById('note-ticket-state').value : '';
|
||||
|
||||
if ((body !== '<p><br></p>' && body !== '') || subject !== '' || time_unit !== '' || this.noteAttachments.length > 0 || ticket_state_id !== '') {
|
||||
@@ -3587,7 +3588,11 @@ const TicketDetailView = {
|
||||
<div class="upload-file-list" id="note-file-list" style="margin-bottom: var(--space-sm);"></div>
|
||||
|
||||
<div style="display:flex; gap:var(--space-md); justify-content:space-between; align-items:center; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
|
||||
<div style="display:flex; align-items:center; gap:6px;">
|
||||
<label for="note-time-units-bottom" style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Tempo (min):</label>
|
||||
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units-bottom" placeholder="minuti" style="width:90px; margin-bottom:0; height:32px; padding:4px 8px; font-size:0.85rem;" />
|
||||
</div>
|
||||
<input type="file" id="note-attachments" multiple style="display:none;" />
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="btn-note-add-attachments" style="height:32px; padding: 4px 10px; font-size: 0.85rem; display: flex; align-items: center; gap: 4px;">📎 Allega file</button>
|
||||
</div>
|
||||
@@ -3991,6 +3996,9 @@ const TicketDetailView = {
|
||||
if (document.getElementById('note-time-units')) {
|
||||
document.getElementById('note-time-units').value = noteDraft.time_unit || '';
|
||||
}
|
||||
if (document.getElementById('note-time-units-bottom')) {
|
||||
document.getElementById('note-time-units-bottom').value = noteDraft.time_unit || '';
|
||||
}
|
||||
if (document.getElementById('note-ticket-state') && noteDraft.ticket_state_id) {
|
||||
document.getElementById('note-ticket-state').value = noteDraft.ticket_state_id;
|
||||
}
|
||||
@@ -4024,6 +4032,7 @@ const TicketDetailView = {
|
||||
const customerIdInput = document.getElementById('qe-customer-id');
|
||||
const noteSubjectInput = document.getElementById('note-subject');
|
||||
const noteTimeUnitsInput = document.getElementById('note-time-units');
|
||||
const noteTimeUnitsBottomInput = document.getElementById('note-time-units-bottom');
|
||||
const noteCreateTimeInput = document.getElementById('note-create-time');
|
||||
|
||||
const checkChanges = () => {
|
||||
@@ -4047,7 +4056,8 @@ const TicketDetailView = {
|
||||
const noteBodyText = (this.noteQuill ? this.noteQuill.root.innerText : '').trim();
|
||||
if (noteBodyText.length > 0) hasChanges = true;
|
||||
|
||||
const noteTimeUnitsVal = parseFloat(noteTimeUnitsInput ? noteTimeUnitsInput.value : '0') || 0;
|
||||
const timeValStr = (noteTimeUnitsInput ? noteTimeUnitsInput.value : '') || (noteTimeUnitsBottomInput ? noteTimeUnitsBottomInput.value : '');
|
||||
const noteTimeUnitsVal = parseFloat(timeValStr || '0') || 0;
|
||||
if (noteTimeUnitsVal > 0) hasChanges = true;
|
||||
|
||||
if (noteCreateTimeInput && noteCreateTimeInput.value) hasChanges = true;
|
||||
@@ -4065,7 +4075,20 @@ const TicketDetailView = {
|
||||
noteSubjectInput.addEventListener('input', checkChanges);
|
||||
}
|
||||
if (noteTimeUnitsInput) {
|
||||
noteTimeUnitsInput.addEventListener('input', checkChanges);
|
||||
noteTimeUnitsInput.addEventListener('input', () => {
|
||||
if (noteTimeUnitsBottomInput && noteTimeUnitsBottomInput.value !== noteTimeUnitsInput.value) {
|
||||
noteTimeUnitsBottomInput.value = noteTimeUnitsInput.value;
|
||||
}
|
||||
checkChanges();
|
||||
});
|
||||
}
|
||||
if (noteTimeUnitsBottomInput) {
|
||||
noteTimeUnitsBottomInput.addEventListener('input', () => {
|
||||
if (noteTimeUnitsInput && noteTimeUnitsInput.value !== noteTimeUnitsBottomInput.value) {
|
||||
noteTimeUnitsInput.value = noteTimeUnitsBottomInput.value;
|
||||
}
|
||||
checkChanges();
|
||||
});
|
||||
}
|
||||
if (noteCreateTimeInput) {
|
||||
noteCreateTimeInput.addEventListener('change', checkChanges);
|
||||
@@ -4093,6 +4116,7 @@ const TicketDetailView = {
|
||||
if (noteSubjectInput) noteSubjectInput.value = '';
|
||||
if (this.noteQuill) this.noteQuill.root.innerHTML = '';
|
||||
if (noteTimeUnitsInput) noteTimeUnitsInput.value = '';
|
||||
if (noteTimeUnitsBottomInput) noteTimeUnitsBottomInput.value = '';
|
||||
if (noteCreateTimeInput) noteCreateTimeInput.value = '';
|
||||
this.noteAttachments = [];
|
||||
this.updateNoteAttachmentList();
|
||||
@@ -4177,36 +4201,53 @@ const TicketDetailView = {
|
||||
customerSearchInput.addEventListener('input', () => {
|
||||
clearTimeout(customerDebounce);
|
||||
const q = customerSearchInput.value.trim();
|
||||
if (customerUserIdInput) customerUserIdInput.value = '';
|
||||
if (customerIdInput) customerIdInput.value = '';
|
||||
const origCustFirst = (this.originalValues['customer_first'] || '').trim();
|
||||
const origCustLast = (this.originalValues['customer_last'] || '').trim();
|
||||
const origCustName = origCustFirst ? `${origCustFirst} ${origCustLast}`.trim() : (this.originalValues['customer_user_id'] || '').trim();
|
||||
|
||||
if (q !== origCustName) {
|
||||
if (customerUserIdInput) customerUserIdInput.value = '';
|
||||
if (customerIdInput) customerIdInput.value = '';
|
||||
} else {
|
||||
if (customerUserIdInput) customerUserIdInput.value = this.originalValues['customer_user_id'] || '';
|
||||
if (customerIdInput) customerIdInput.value = this.originalValues['customer_id'] || '';
|
||||
}
|
||||
checkChanges();
|
||||
|
||||
customerDebounce = setTimeout(async () => {
|
||||
try {
|
||||
const customers = await App.api(`/api/customers/search?q=${encodeURIComponent(q)}`);
|
||||
if (!customers || customers.length === 0) {
|
||||
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||||
if (!users || users.length === 0) {
|
||||
customerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default; padding: 6px 12px; font-size:0.78rem;">Nessun cliente trovato</div>';
|
||||
customerSuggestionsDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
customerSuggestionsDiv.innerHTML = customers.map(c => `
|
||||
<div class="autocomplete-suggestion-item" data-user-id="${App.escapeHtml(c.customer_user_id)}" data-customer-id="${App.escapeHtml(c.customer_id || c.customer_user_id)}" data-name="${App.escapeHtml(c.first_name ? `${c.first_name} ${c.last_name}` : c.customer_user_id)}" style="padding: 6px 12px; font-size: 0.78rem; cursor: pointer;">
|
||||
<strong>${App.escapeHtml(c.first_name ? `${c.first_name} ${c.last_name}` : c.customer_user_id)}</strong>
|
||||
${c.email ? `<span style="color:var(--text-muted); margin-left:6px;">(${App.escapeHtml(c.email)})</span>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
customerSuggestionsDiv.innerHTML = users.map(u => {
|
||||
const fullName = (u.first_name || u.last_name) ? `${u.first_name || ''} ${u.last_name || ''}`.trim() : (u.login || '');
|
||||
return `
|
||||
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login || '')}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(fullName)}" style="padding: 6px 12px; font-size: 0.78rem; cursor: pointer;">
|
||||
<strong>${App.escapeHtml(fullName)}</strong>
|
||||
<span style="color:var(--text-muted); margin-left:6px; font-size:0.75rem;">(Login: ${App.escapeHtml(u.login || '')}${u.customer_id ? ` | ${App.escapeHtml(u.customer_id)}` : ''})</span>
|
||||
${u.email ? `<div style="color:var(--text-secondary); font-size:0.73rem;">${App.escapeHtml(u.email)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
customerSuggestionsDiv.style.display = 'block';
|
||||
|
||||
const selectCustomerItem = (item) => {
|
||||
customerSearchInput.value = item.dataset.name;
|
||||
if (customerUserIdInput) customerUserIdInput.value = item.dataset.login;
|
||||
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
|
||||
customerSuggestionsDiv.style.display = 'none';
|
||||
checkChanges();
|
||||
};
|
||||
|
||||
customerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
|
||||
if (item.dataset.userId) {
|
||||
if (item.dataset.login) {
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
customerSearchInput.value = item.dataset.name;
|
||||
if (customerUserIdInput) customerUserIdInput.value = item.dataset.userId;
|
||||
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
|
||||
customerSuggestionsDiv.style.display = 'none';
|
||||
checkChanges();
|
||||
selectCustomerItem(item);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4216,6 +4257,18 @@ const TicketDetailView = {
|
||||
}, 150);
|
||||
});
|
||||
|
||||
if (App.setupAutocompleteKeyboardNavigation) {
|
||||
App.setupAutocompleteKeyboardNavigation(customerSearchInput, customerSuggestionsDiv, (item) => {
|
||||
if (item && item.dataset.login) {
|
||||
customerSearchInput.value = item.dataset.name;
|
||||
if (customerUserIdInput) customerUserIdInput.value = item.dataset.login;
|
||||
if (customerIdInput) customerIdInput.value = item.dataset.customerId;
|
||||
customerSuggestionsDiv.style.display = 'none';
|
||||
checkChanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
customerSearchInput.addEventListener('focus', () => {
|
||||
customerSearchInput.dispatchEvent(new Event('input'));
|
||||
});
|
||||
@@ -4246,9 +4299,18 @@ const TicketDetailView = {
|
||||
}
|
||||
|
||||
const customerSearch = customerSearchInput ? customerSearchInput.value.trim() : '';
|
||||
if (customerSearch && customerUserIdInput && !customerUserIdInput.value) {
|
||||
customerUserIdInput.value = customerSearch;
|
||||
if (customerIdInput) customerIdInput.value = customerSearch;
|
||||
const origCustFirst = (this.originalValues['customer_first'] || '').trim();
|
||||
const origCustLast = (this.originalValues['customer_last'] || '').trim();
|
||||
const origCustName = origCustFirst ? `${origCustFirst} ${origCustLast}`.trim() : (this.originalValues['customer_user_id'] || '').trim();
|
||||
|
||||
if (customerSearchInput) {
|
||||
if (!customerSearch) {
|
||||
if (customerUserIdInput) customerUserIdInput.value = '';
|
||||
if (customerIdInput) customerIdInput.value = '';
|
||||
} else if (customerSearch === origCustName) {
|
||||
if (customerUserIdInput) customerUserIdInput.value = this.originalValues['customer_user_id'] || '';
|
||||
if (customerIdInput) customerIdInput.value = this.originalValues['customer_id'] || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Check metadata updates
|
||||
|
||||
@@ -250,53 +250,81 @@ const TicketListView = {
|
||||
|
||||
container.innerHTML = `
|
||||
<!-- Batch Actions Bar -->
|
||||
<div class="batch-bar" id="batch-bar">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns" style="margin-left: 8px;">Copia numeri</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Stato</span>
|
||||
<select class="filter-select" id="batch-state">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Coda</span>
|
||||
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:180px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
<input type="hidden" id="batch-queue" />
|
||||
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; min-width: 480px; width: max-content; max-width: 85vw; z-index: 1001;"></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Owner</span>
|
||||
<select class="filter-select" id="batch-owner">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Responsabile</span>
|
||||
<select class="filter-select" id="batch-responsible">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
${(App.lookups.types || []).length > 0 ? `
|
||||
<!-- Batch Actions Bar -->
|
||||
<div class="batch-bar" id="batch-bar" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-md); flex-wrap: wrap;">
|
||||
<!-- Colonna Sinistra: Configurazioni e Filtri di Modifica -->
|
||||
<div class="batch-bar-left" style="display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1; min-width: 300px;">
|
||||
<span class="batch-count" id="batch-count" style="margin-right: 4px;">0 selezionati</span>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Tipo</span>
|
||||
<select class="filter-select" id="batch-type">
|
||||
<span class="filter-label">Stato</span>
|
||||
<select class="filter-select" id="batch-state">
|
||||
<option value="">—</option>
|
||||
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||
${(App.lookups.states || []).map(s => `<option value="${s.id}">${s.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
` : ''}
|
||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Aggiungi a gruppo</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); margin-left: 8px;">Unisci Selezionati</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled style="background: var(--accent-primary); border-color: var(--accent-primary); margin-left: 8px;">Apri ticket in schede</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||
<div class="filter-group" style="position:relative;">
|
||||
<span class="filter-label">Coda</span>
|
||||
<input type="text" class="form-input filter-select" id="batch-queue-search" placeholder="Cerca coda..." autocomplete="off" style="width:180px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
<input type="hidden" id="batch-queue" />
|
||||
<div id="batch-queue-suggestions" class="autocomplete-suggestions" style="display:none; top: 100%; left: 0; min-width: 480px; width: max-content; max-width: 85vw; z-index: 1001;"></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Owner</span>
|
||||
<select class="filter-select" id="batch-owner">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Responsabile</span>
|
||||
<select class="filter-select" id="batch-responsible">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.users || []).map(u => `<option value="${u.id}">${u.first_name} ${u.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
${(App.lookups.types || []).length > 0 ? `
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Tipo</span>
|
||||
<select class="filter-select" id="batch-type">
|
||||
<option value="">—</option>
|
||||
${App.lookups.types.map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Colonna Destra: Pulsanti delle Azioni -->
|
||||
<div class="batch-bar-right" style="display: flex; flex-direction: column; align-items: flex-end; gap: 6px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-add-group" disabled title="Aggiungi a gruppo" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled title="Unisci Selezionati" style="background: rgba(160, 65, 71, 0.32); border-color: var(--accent-primary); color: var(--text-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<circle cx="18" cy="18" r="3"></circle>
|
||||
<circle cx="6" cy="6" r="3"></circle>
|
||||
<path d="M6 21V9a9 9 0 0 0 9 9"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-open-tabs" disabled title="Apri ticket in schede" style="background: var(--accent-primary); border-color: var(--accent-primary); height: 32px; width: 34px; padding: 0; display: inline-flex; align-items: center; justify-content: center;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||||
<path d="M2 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9z"></path>
|
||||
<path d="M6 3h12a2 2 0 0 1 2 2v12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
|
||||
<button class="btn btn-ghost btn-xs" id="batch-select-all">Seleziona visibili</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-copy-tns">Copia numeri</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel" title="Annulla selezione">Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${Filters.renderBar(App.lookups)}
|
||||
|
||||
@@ -296,6 +296,12 @@ router.get('/customer-companies/search', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Alias /api/customers/search -> /api/customer-users/search for backwards compatibility
|
||||
router.get('/customers/search', (req, res, next) => {
|
||||
req.url = '/customer-users/search' + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
|
||||
router.handle(req, res, next);
|
||||
});
|
||||
|
||||
// GET /api/customer-users/search — Search customer users (supports LDAP via REST, fallbacks to DB)
|
||||
router.get('/customer-users/search', async (req, res) => {
|
||||
const { q = '', customer_company_id } = req.query;
|
||||
|
||||
+73
-36
@@ -387,7 +387,11 @@ router.get('/:id', async (req, res, next) => {
|
||||
// 1. Try to read from local SQLite cache in internal.db
|
||||
let cachedUser = null;
|
||||
try {
|
||||
cachedUser = db.prepare('SELECT email, first_name, last_name, phone FROM customer_user_cache WHERE login = ?').get(ticket.customer_user_id);
|
||||
cachedUser = db.prepare(`
|
||||
SELECT login, email, first_name, last_name, phone
|
||||
FROM customer_user_cache
|
||||
WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE
|
||||
`).get(ticket.customer_user_id, ticket.customer_user_id);
|
||||
} catch (dbErr) {
|
||||
console.warn('[LDAP Cache Detail] SQLite cache read failed:', dbErr.message);
|
||||
}
|
||||
@@ -398,37 +402,50 @@ router.get('/:id', async (req, res, next) => {
|
||||
ticket.customer_email = cachedUser.email || '';
|
||||
ticket.customer_phone = cachedUser.phone || '';
|
||||
} else {
|
||||
// 2. Fallback to live API fetch
|
||||
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: ticket.customer_user_id });
|
||||
const getData = (getResult && getResult.Data) || getResult;
|
||||
if (getData && getData.CustomerUser) {
|
||||
const u = getData.CustomerUser;
|
||||
ticket.customer_first = u.UserFirstname || '';
|
||||
ticket.customer_last = u.UserLastname || '';
|
||||
ticket.customer_email = u.UserEmail || '';
|
||||
ticket.customer_phone = u.UserPhone || '';
|
||||
|
||||
// 3. Store in SQLite cache for subsequent detail queries
|
||||
// 2. Fallback to live API fetch (only if login doesn't contain spaces)
|
||||
let targetLogin = ticket.customer_user_id;
|
||||
if (targetLogin.includes(' ')) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO customer_user_cache (login, email, first_name, last_name, customer_id, phone)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login) DO UPDATE SET
|
||||
email=excluded.email,
|
||||
first_name=excluded.first_name,
|
||||
last_name=excluded.last_name,
|
||||
customer_id=excluded.customer_id,
|
||||
phone=excluded.phone
|
||||
`).run(
|
||||
u.UserLogin,
|
||||
u.UserEmail || '',
|
||||
u.UserFirstname || '',
|
||||
u.UserLastname || '',
|
||||
u.UserCustomerID || '',
|
||||
u.UserPhone || ''
|
||||
);
|
||||
} catch (cacheErr) {
|
||||
console.warn('[LDAP Cache Detail] Failed to write fetched user to SQLite cache:', cacheErr.message);
|
||||
const sRes = await otrsRequest('POST', '/CustomerUserSearch', { Search: `*${targetLogin}*`, Valid: 1 });
|
||||
const sData = (sRes && sRes.Data) || sRes;
|
||||
if (sData && sData.CustomerUserID) {
|
||||
targetLogin = Array.isArray(sData.CustomerUserID) ? sData.CustomerUserID[0] : sData.CustomerUserID;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (targetLogin && !targetLogin.includes(' ')) {
|
||||
const getResult = await otrsRequest('POST', '/CustomerUserGet', { UserLogin: targetLogin });
|
||||
const getData = (getResult && getResult.Data) || getResult;
|
||||
if (getData && getData.CustomerUser) {
|
||||
const u = getData.CustomerUser;
|
||||
ticket.customer_first = u.UserFirstname || '';
|
||||
ticket.customer_last = u.UserLastname || '';
|
||||
ticket.customer_email = u.UserEmail || '';
|
||||
ticket.customer_phone = u.UserPhone || '';
|
||||
|
||||
// 3. Store in SQLite cache for subsequent detail queries
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO customer_user_cache (login, email, first_name, last_name, customer_id, phone)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(login) DO UPDATE SET
|
||||
email=excluded.email,
|
||||
first_name=excluded.first_name,
|
||||
last_name=excluded.last_name,
|
||||
customer_id=excluded.customer_id,
|
||||
phone=excluded.phone
|
||||
`).run(
|
||||
u.UserLogin,
|
||||
u.UserEmail || '',
|
||||
u.UserFirstname || '',
|
||||
u.UserLastname || '',
|
||||
u.UserCustomerID || '',
|
||||
u.UserPhone || ''
|
||||
);
|
||||
} catch (cacheErr) {
|
||||
console.warn('[LDAP Cache Detail] Failed to write fetched user to SQLite cache:', cacheErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -940,9 +957,19 @@ router.patch('/:id', async (req, res) => {
|
||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||
if (updates.title !== undefined) ticketFields.Title = updates.title;
|
||||
if (updates.ticket_lock_id !== undefined) ticketFields.LockID = updates.ticket_lock_id;
|
||||
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') {
|
||||
try {
|
||||
const match = db.prepare(`SELECT login, customer_id FROM customer_user_cache WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE`).get(updates.customer_user_id, updates.customer_user_id);
|
||||
if (match) {
|
||||
updates.customer_user_id = match.login;
|
||||
if (!updates.customer_id && match.customer_id) {
|
||||
updates.customer_id = match.customer_id;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
ticketFields.CustomerUser = updates.customer_user_id;
|
||||
}
|
||||
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
|
||||
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') ticketFields.CustomerUser = updates.customer_user_id;
|
||||
|
||||
// If ticketFields is empty but we have a note or timeUnit to log, supply a dummy field (StateID) so Ticket is not empty
|
||||
if (Object.keys(ticketFields).length === 0 && (hasNote || (!isNaN(timeUnit) && timeUnit > 0))) {
|
||||
@@ -1673,9 +1700,19 @@ router.patch('/batch/update', async (req, res) => {
|
||||
if (updates.queue_id !== undefined) ticketFields.QueueID = updates.queue_id;
|
||||
if (updates.type_id !== undefined) ticketFields.TypeID = updates.type_id;
|
||||
if (updates.user_id !== undefined) ticketFields.OwnerID = updates.user_id;
|
||||
if (updates.responsible_user_id !== undefined) ticketFields.ResponsibleID = updates.responsible_user_id;
|
||||
if (updates.customer_id !== undefined) ticketFields.CustomerID = updates.customer_id;
|
||||
if (updates.customer_user_id !== undefined) ticketFields.CustomerUser = updates.customer_user_id;
|
||||
if (updates.customer_user_id !== undefined && updates.customer_user_id !== null && updates.customer_user_id !== '') {
|
||||
try {
|
||||
const match = db.prepare(`SELECT login, customer_id FROM customer_user_cache WHERE login = ? OR (first_name || ' ' || last_name) = ? COLLATE NOCASE`).get(updates.customer_user_id, updates.customer_user_id);
|
||||
if (match) {
|
||||
updates.customer_user_id = match.login;
|
||||
if (!updates.customer_id && match.customer_id) {
|
||||
updates.customer_id = match.customer_id;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
ticketFields.CustomerUser = updates.customer_user_id;
|
||||
}
|
||||
if (updates.customer_id !== undefined && updates.customer_id !== null && updates.customer_id !== '') ticketFields.CustomerID = updates.customer_id;
|
||||
|
||||
// Auto sblocco check
|
||||
if (updates.ticket_state_id) {
|
||||
|
||||
Reference in new issue
Block a user