Aggiunte stored procedure più utili le altre pfff
This commit is contained in:
+61
-3
@@ -57,6 +57,12 @@ const App = {
|
||||
// Init active agent selector
|
||||
this.initAgentSelector();
|
||||
|
||||
// Bind refresh lookups button
|
||||
const refreshBtn = document.getElementById('refresh-lookups-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => this.refreshLookups());
|
||||
}
|
||||
|
||||
// Initial route
|
||||
if (!window.location.hash || window.location.hash === '#/') {
|
||||
window.location.hash = '#/dashboard';
|
||||
@@ -67,7 +73,8 @@ const App = {
|
||||
|
||||
/** Route based on current hash */
|
||||
route() {
|
||||
const hash = window.location.hash || '#/dashboard';
|
||||
const fullHash = window.location.hash || '#/dashboard';
|
||||
const hash = fullHash.split('?')[0];
|
||||
const titleEl = document.getElementById('page-title');
|
||||
|
||||
// Update active nav link
|
||||
@@ -90,6 +97,11 @@ const App = {
|
||||
titleEl.textContent = 'Nuovo Ticket';
|
||||
TicketCreateView.render();
|
||||
|
||||
} else if (hash === '#/tickets/bulk') {
|
||||
document.getElementById('nav-bulk-tickets')?.classList.add('active');
|
||||
titleEl.textContent = 'Apertura Massiva Ticket';
|
||||
TicketBulkView.render();
|
||||
|
||||
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
||||
document.getElementById('nav-tickets')?.classList.add('active');
|
||||
@@ -124,8 +136,22 @@ const App = {
|
||||
},
|
||||
|
||||
/** Ensure lookup data is loaded (cached) */
|
||||
async ensureLookups() {
|
||||
if (this.lookupsLoaded) return;
|
||||
async ensureLookups(forceRefresh = false) {
|
||||
if (this.lookupsLoaded && !forceRefresh) return;
|
||||
|
||||
// Check localStorage cache first if not forcing refresh
|
||||
if (!forceRefresh) {
|
||||
const cached = localStorage.getItem('otrs_lookups');
|
||||
if (cached) {
|
||||
try {
|
||||
this.lookups = JSON.parse(cached);
|
||||
this.lookupsLoaded = true;
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse cached lookups, reloading...', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [queues, states, priorities, users, types] = await Promise.all([
|
||||
@@ -137,6 +163,7 @@ const App = {
|
||||
]);
|
||||
|
||||
this.lookups = { queues, states, priorities, users, types };
|
||||
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
|
||||
this.lookupsLoaded = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to load lookups:', err);
|
||||
@@ -144,6 +171,37 @@ const App = {
|
||||
}
|
||||
},
|
||||
|
||||
/** Force refresh of lookups */
|
||||
async refreshLookups() {
|
||||
const btn = document.getElementById('refresh-lookups-btn');
|
||||
const origHtml = btn ? btn.innerHTML : '';
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;margin:0;"></div>';
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureLookups(true);
|
||||
await this.initAgentSelector();
|
||||
Toast.success('Dati locali (code, utenti, ecc.) aggiornati con successo!');
|
||||
|
||||
// If we are on a view that needs lookups, we can re-render it
|
||||
const hash = window.location.hash;
|
||||
if (hash === '#/tickets/bulk') {
|
||||
TicketBulkView.render();
|
||||
} else if (hash === '#/tickets/new') {
|
||||
TicketCreateView.render();
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.error('Errore durante l\'aggiornamento: ' + err.message);
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Check database connection */
|
||||
async checkConnection() {
|
||||
const dot = document.getElementById('connection-status');
|
||||
|
||||
@@ -8,6 +8,8 @@ const Filters = {
|
||||
state_id: '',
|
||||
priority_id: '',
|
||||
user_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
},
|
||||
|
||||
/** Load saved filters from localStorage */
|
||||
@@ -29,7 +31,7 @@ const Filters = {
|
||||
|
||||
/** Reset all filters */
|
||||
reset() {
|
||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '' };
|
||||
this.state = { queue_id: '', state_id: '', priority_id: '', user_id: '', date_from: '', date_to: '' };
|
||||
this.save();
|
||||
},
|
||||
|
||||
@@ -87,6 +89,14 @@ const Filters = {
|
||||
${makeOptions(lookups.users || [], 'id', (u) => `${u.first_name} ${u.last_name}`, this.state.user_id)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<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;" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">A Data/Ora</span>
|
||||
<input type="datetime-local" class="filter-select" data-filter="date_to" id="filter-date-to" value="${this.state.date_to || ''}" style="width: 190px; padding: 4px 8px; font-family: inherit; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div class="filters-actions">
|
||||
<button class="btn btn-ghost btn-xs" id="filter-reset">Reset</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* Ticket Bulk Creation View
|
||||
* Tabular layout for creating multiple tickets.
|
||||
*/
|
||||
const TicketBulkView = {
|
||||
rowCount: 0,
|
||||
activeRequests: false,
|
||||
|
||||
async render() {
|
||||
const container = document.getElementById('view-container');
|
||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dati...</p></div>';
|
||||
|
||||
try {
|
||||
await App.ensureLookups();
|
||||
|
||||
// Check if we have incoming query parameters (e.g. from Teams integration)
|
||||
const hashParts = window.location.hash.split('?');
|
||||
const searchStr = hashParts.length > 1 ? hashParts[1] : window.location.search;
|
||||
const urlParams = new URLSearchParams(searchStr);
|
||||
const incomingBody = urlParams.get('body');
|
||||
const incomingCustomer = urlParams.get('customer');
|
||||
const incomingSubject = urlParams.get('subject');
|
||||
|
||||
if (incomingBody || incomingCustomer || incomingSubject) {
|
||||
let foundCustomer = null;
|
||||
if (incomingCustomer) {
|
||||
try {
|
||||
const results = await App.api(`/api/customer-users/search?q=${encodeURIComponent(incomingCustomer)}`);
|
||||
if (results && results.length > 0) {
|
||||
foundCustomer = results[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to search customer for Teams integration:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const initialRowData = {
|
||||
state_id: '1',
|
||||
title: 'Segnalazione da Teams',
|
||||
queue_name: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].name : '',
|
||||
queue_id: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].id : '',
|
||||
type_id: '',
|
||||
owner_name: '',
|
||||
owner_id: '',
|
||||
responsible_name: '',
|
||||
responsible_id: '',
|
||||
customer_search: foundCustomer ? `${foundCustomer.first_name} ${foundCustomer.last_name}` : (incomingCustomer || ''),
|
||||
customer_id: foundCustomer ? foundCustomer.customer_id : '',
|
||||
customer_user_id: foundCustomer ? foundCustomer.login : (incomingCustomer || ''),
|
||||
subject: incomingSubject || 'Messaggio da Microsoft Teams',
|
||||
body: incomingBody || '',
|
||||
priority_id: '3',
|
||||
company_display: foundCustomer ? foundCustomer.customer_id : '',
|
||||
isExpanded: true,
|
||||
statusDotClass: '',
|
||||
statusDotTitle: ''
|
||||
};
|
||||
|
||||
if (!this.savedRows) this.savedRows = [];
|
||||
this.savedRows.unshift(initialRowData);
|
||||
|
||||
// Clean query parameters from URL to prevent duplicate inserts on reload
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash.split('?')[0]);
|
||||
}
|
||||
|
||||
this.rowCount = 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="card" style="padding: var(--space-md); overflow: visible;">
|
||||
<div class="card-title" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:var(--space-md);">
|
||||
<span>Apertura Massiva Ticket</span>
|
||||
<span style="font-size:0.75rem; color:var(--text-tertiary); font-weight:normal; text-transform:none;">
|
||||
Usa il tasto <kbd style="background:rgba(255,255,255,0.1); padding:2px 6px; border-radius:3px;">Tab</kbd> per spostarti e creare nuove righe in automatico.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bulk-grid-wrapper">
|
||||
<table class="bulk-grid-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px; text-align:center;">#</th>
|
||||
<th style="width:120px;">Stato *</th>
|
||||
<th style="width:200px;">Titolo *</th>
|
||||
<th style="width:540px;">Coda *</th>
|
||||
<th style="width:110px;">Tipo</th>
|
||||
<th style="width:260px;">Owner</th>
|
||||
<th style="width:260px;">Responsabile</th>
|
||||
<th style="width:300px;">Cliente *</th>
|
||||
<th style="width:200px;">Oggetto</th>
|
||||
<th style="width:200px;">Messaggio</th>
|
||||
<th style="width:90px; text-align:center;">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bulk-rows-container">
|
||||
<!-- Rows will be added dynamically here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="bulk-actions-container">
|
||||
<div>
|
||||
<button class="btn btn-ghost btn-sm" id="bulk-add-row-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Aggiungi riga
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:var(--space-md); align-items:center;">
|
||||
<button class="btn btn-ghost btn-sm" onclick="window.location.hash='#/tickets'">Annulla</button>
|
||||
<button class="btn btn-primary" id="bulk-submit-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;">
|
||||
<path d="M5 12l5 5L20 7"/>
|
||||
</svg>
|
||||
Conferma e Invia
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('bulk-add-row-btn').addEventListener('click', () => this.addRow());
|
||||
document.getElementById('bulk-submit-btn').addEventListener('click', () => this.submitBulk());
|
||||
|
||||
// Add first row or restore saved rows
|
||||
if (this.savedRows && this.savedRows.length > 0) {
|
||||
this.savedRows.forEach(rowData => this.addRow(rowData));
|
||||
} else {
|
||||
this.addRow();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error rendering bulk view:', err);
|
||||
container.innerHTML = `<div class="error-screen"><p>Errore caricamento: ${err.message}</p></div>`;
|
||||
}
|
||||
},
|
||||
|
||||
addRow(savedData = null) {
|
||||
this.rowCount++;
|
||||
const id = this.rowCount;
|
||||
const container = document.getElementById('bulk-rows-container');
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.id = `bulk-row-${id}`;
|
||||
tr.className = 'bulk-row';
|
||||
tr.innerHTML = `
|
||||
<td style="text-align:center; font-weight:600; color:var(--text-tertiary);">
|
||||
<span class="row-status-dot" id="status-dot-${id}"></span><span class="row-num">${id}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select class="bulk-select" id="bulk-state-${id}">
|
||||
${(App.lookups.states || []).map(s => {
|
||||
const sel = s.type_name === 'new' ? 'selected' : '';
|
||||
return `<option value="${s.id}" ${sel}>${s.name}</option>`;
|
||||
}).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="bulk-input" id="bulk-title-${id}" placeholder="Titolo ticket" autocomplete="new-password" required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="bulk-input" id="bulk-queue-search-${id}" placeholder="Coda..." autocomplete="new-password" />
|
||||
<input type="hidden" id="bulk-queue-${id}" />
|
||||
<div id="bulk-queue-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="bulk-select" id="bulk-type-${id}">
|
||||
<option value="">—</option>
|
||||
${(App.lookups.types || []).map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="bulk-input" id="bulk-owner-search-${id}" placeholder="Cerca owner..." autocomplete="new-password" />
|
||||
<input type="hidden" id="bulk-owner-${id}" />
|
||||
<div id="bulk-owner-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="bulk-input" id="bulk-responsible-search-${id}" placeholder="Cerca responsabile..." autocomplete="new-password" />
|
||||
<input type="hidden" id="bulk-responsible-${id}" />
|
||||
<div id="bulk-responsible-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="bulk-input" id="bulk-customer-search-${id}" placeholder="Nome, email, login..." autocomplete="new-password" required />
|
||||
<input type="hidden" id="bulk-customer-${id}" />
|
||||
<input type="hidden" id="bulk-customer-user-id-${id}" />
|
||||
<div id="bulk-customer-suggestions-${id}" class="bulk-suggestions" style="display:none;"></div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="bulk-input" id="bulk-subject-${id}" placeholder="Oggetto nota" autocomplete="new-password" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="bulk-textarea-container">
|
||||
<textarea class="bulk-textarea" id="bulk-body-${id}" placeholder="Scrivi nota..."></textarea>
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<div class="row-actions">
|
||||
<button class="btn btn-ghost btn-xs" id="bulk-expand-btn-${id}" title="Opzioni avanzate">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-xs" id="bulk-delete-btn-${id}" title="Rimuovi riga">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
// Collapsible advanced row
|
||||
const trExp = document.createElement('tr');
|
||||
trExp.id = `bulk-row-expanded-${id}`;
|
||||
trExp.className = 'bulk-row-expanded';
|
||||
trExp.style.display = 'none';
|
||||
trExp.innerHTML = `
|
||||
<td colspan="11">
|
||||
<div class="bulk-row-expanded-content">
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label class="form-label" style="font-size:0.75rem;">Priorità</label>
|
||||
<select class="form-select" id="bulk-priority-${id}" style="height:32px; font-size:0.8rem; padding: 4px 8px; margin:0;">
|
||||
${(App.lookups.priorities || []).map(p => {
|
||||
const sel = p.id === 3 ? 'selected' : '';
|
||||
return `<option value="${p.id}" ${sel}>${p.name}</option>`;
|
||||
}).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label class="form-label" style="font-size:0.75rem;">Azienda Cliente (Società)</label>
|
||||
<input type="text" class="form-input" id="bulk-company-display-${id}" readonly disabled placeholder="Rilevata dal cliente" style="height:32px; font-size:0.8rem; background:rgba(255,255,255,0.05); margin:0;" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
container.appendChild(tr);
|
||||
container.appendChild(trExp);
|
||||
|
||||
// Populate with savedData if available, otherwise set default values
|
||||
if (savedData) {
|
||||
tr.querySelector(`#bulk-state-${id}`).value = savedData.state_id;
|
||||
tr.querySelector(`#bulk-title-${id}`).value = savedData.title;
|
||||
tr.querySelector(`#bulk-queue-search-${id}`).value = savedData.queue_name;
|
||||
tr.querySelector(`#bulk-queue-${id}`).value = savedData.queue_id;
|
||||
tr.querySelector(`#bulk-type-${id}`).value = savedData.type_id;
|
||||
tr.querySelector(`#bulk-owner-search-${id}`).value = savedData.owner_name;
|
||||
tr.querySelector(`#bulk-owner-${id}`).value = savedData.owner_id;
|
||||
tr.querySelector(`#bulk-responsible-search-${id}`).value = savedData.responsible_name;
|
||||
tr.querySelector(`#bulk-responsible-${id}`).value = savedData.responsible_id;
|
||||
tr.querySelector(`#bulk-customer-search-${id}`).value = savedData.customer_search;
|
||||
tr.querySelector(`#bulk-customer-${id}`).value = savedData.customer_id;
|
||||
tr.querySelector(`#bulk-customer-user-id-${id}`).value = savedData.customer_user_id;
|
||||
tr.querySelector(`#bulk-subject-${id}`).value = savedData.subject;
|
||||
tr.querySelector(`#bulk-body-${id}`).value = savedData.body;
|
||||
trExp.querySelector(`#bulk-priority-${id}`).value = savedData.priority_id;
|
||||
trExp.querySelector(`#bulk-company-display-${id}`).value = savedData.company_display;
|
||||
|
||||
if (savedData.isExpanded) {
|
||||
trExp.style.display = 'table-row';
|
||||
const btn = tr.querySelector(`#bulk-expand-btn-${id}`);
|
||||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M5 12h14"/></svg>`;
|
||||
}
|
||||
|
||||
const dot = tr.querySelector(`#status-dot-${id}`);
|
||||
if (savedData.statusDotClass) dot.className = savedData.statusDotClass;
|
||||
if (savedData.statusDotTitle) dot.title = savedData.statusDotTitle;
|
||||
|
||||
} else {
|
||||
// Default queue pre-population
|
||||
const defaultQueueInput = tr.querySelector(`#bulk-queue-search-${id}`);
|
||||
const defaultQueueHidden = tr.querySelector(`#bulk-queue-${id}`);
|
||||
if (App.lookups.queues && App.lookups.queues.length > 0) {
|
||||
defaultQueueInput.value = App.lookups.queues[0].name;
|
||||
defaultQueueHidden.value = App.lookups.queues[0].id;
|
||||
}
|
||||
|
||||
// Default owner and responsible (Active Agent)
|
||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(activeAgentId));
|
||||
if (activeAgent) {
|
||||
const agentName = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||
tr.querySelector(`#bulk-owner-search-${id}`).value = agentName;
|
||||
tr.querySelector(`#bulk-owner-${id}`).value = activeAgent.id;
|
||||
tr.querySelector(`#bulk-responsible-search-${id}`).value = agentName;
|
||||
tr.querySelector(`#bulk-responsible-${id}`).value = activeAgent.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle expand button
|
||||
tr.querySelector(`#bulk-expand-btn-${id}`).addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const isHidden = trExp.style.display === 'none';
|
||||
trExp.style.display = isHidden ? 'table-row' : 'none';
|
||||
const btn = tr.querySelector(`#bulk-expand-btn-${id}`);
|
||||
btn.innerHTML = isHidden
|
||||
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M5 12h14"/></svg>`
|
||||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M12 5v14M5 12h14"/></svg>`;
|
||||
this.saveState();
|
||||
});
|
||||
|
||||
// Delete button
|
||||
tr.querySelector(`#bulk-delete-btn-${id}`).addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
tr.remove();
|
||||
trExp.remove();
|
||||
this.reorderRows();
|
||||
this.saveState();
|
||||
});
|
||||
|
||||
// Setup autocompletes
|
||||
this.setupAutocomplete(id, 'queue', `bulk-queue-search-${id}`, `bulk-queue-${id}`, `bulk-queue-suggestions-${id}`);
|
||||
this.setupAutocomplete(id, 'owner', `bulk-owner-search-${id}`, `bulk-owner-${id}`, `bulk-owner-suggestions-${id}`);
|
||||
this.setupAutocomplete(id, 'responsible', `bulk-responsible-search-${id}`, `bulk-responsible-${id}`, `bulk-responsible-suggestions-${id}`);
|
||||
this.setupAutocomplete(id, 'customer', `bulk-customer-search-${id}`, `bulk-customer-${id}`, `bulk-customer-suggestions-${id}`);
|
||||
|
||||
// Setup Tab key navigation on the last field (body textarea)
|
||||
const textarea = tr.querySelector(`#bulk-body-${id}`);
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab' && !e.shiftKey) {
|
||||
// Check if this is the last visible bulk-row textarea
|
||||
const activeRows = container.querySelectorAll('.bulk-row');
|
||||
const lastRow = activeRows[activeRows.length - 1];
|
||||
if (tr === lastRow) {
|
||||
e.preventDefault();
|
||||
this.addRow();
|
||||
// Focus the next row's first input (Title)
|
||||
setTimeout(() => {
|
||||
const nextId = this.rowCount;
|
||||
document.getElementById(`bulk-title-${nextId}`)?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll table to make inputs and textareas fully visible when focused
|
||||
tr.querySelectorAll('input, select, textarea').forEach(input => {
|
||||
input.addEventListener('focus', () => {
|
||||
const wrapper = document.querySelector('.bulk-grid-wrapper');
|
||||
if (wrapper) {
|
||||
setTimeout(() => {
|
||||
const rect = input.getBoundingClientRect();
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
if (rect.right > wrapperRect.right) {
|
||||
wrapper.scrollBy({ left: rect.right - wrapperRect.right + 40, behavior: 'smooth' });
|
||||
} else if (rect.left < wrapperRect.left) {
|
||||
wrapper.scrollBy({ left: rect.left - wrapperRect.left - 40, behavior: 'smooth' });
|
||||
}
|
||||
if (rect.bottom > wrapperRect.bottom) {
|
||||
wrapper.scrollBy({ top: rect.bottom - wrapperRect.bottom + 40, behavior: 'smooth' });
|
||||
} else if (rect.top < wrapperRect.top) {
|
||||
wrapper.scrollBy({ top: rect.top - wrapperRect.top - 40, behavior: 'smooth' });
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save state on any input change
|
||||
tr.querySelectorAll('input, select, textarea').forEach(input => {
|
||||
input.addEventListener('input', () => this.saveState());
|
||||
input.addEventListener('change', () => this.saveState());
|
||||
});
|
||||
trExp.querySelectorAll('input, select, textarea').forEach(input => {
|
||||
input.addEventListener('input', () => this.saveState());
|
||||
input.addEventListener('change', () => this.saveState());
|
||||
});
|
||||
|
||||
this.saveState();
|
||||
},
|
||||
|
||||
reorderRows() {
|
||||
const container = document.getElementById('bulk-rows-container');
|
||||
const rows = container.querySelectorAll('.bulk-row');
|
||||
rows.forEach((row, index) => {
|
||||
const numSpan = row.querySelector('.row-num');
|
||||
if (numSpan) numSpan.textContent = index + 1;
|
||||
});
|
||||
},
|
||||
|
||||
saveState() {
|
||||
const container = document.getElementById('bulk-rows-container');
|
||||
if (!container) return;
|
||||
const rows = container.querySelectorAll('.bulk-row');
|
||||
const data = [];
|
||||
rows.forEach(row => {
|
||||
const id = row.id.replace('bulk-row-', '');
|
||||
|
||||
const state_id = document.getElementById(`bulk-state-${id}`).value;
|
||||
const title = document.getElementById(`bulk-title-${id}`).value;
|
||||
const queue_name = document.getElementById(`bulk-queue-search-${id}`).value;
|
||||
const queue_id = document.getElementById(`bulk-queue-${id}`).value;
|
||||
const type_id = document.getElementById(`bulk-type-${id}`).value;
|
||||
const owner_name = document.getElementById(`bulk-owner-search-${id}`).value;
|
||||
const owner_id = document.getElementById(`bulk-owner-${id}`).value;
|
||||
const responsible_name = document.getElementById(`bulk-responsible-search-${id}`).value;
|
||||
const responsible_id = document.getElementById(`bulk-responsible-${id}`).value;
|
||||
const customer_search = document.getElementById(`bulk-customer-search-${id}`).value;
|
||||
const customer_id = document.getElementById(`bulk-customer-${id}`).value;
|
||||
const customer_user_id = document.getElementById(`bulk-customer-user-id-${id}`).value;
|
||||
const subject = document.getElementById(`bulk-subject-${id}`).value;
|
||||
const body = document.getElementById(`bulk-body-${id}`).value;
|
||||
|
||||
const priority_id = document.getElementById(`bulk-priority-${id}`).value;
|
||||
const company_display = document.getElementById(`bulk-company-display-${id}`).value;
|
||||
|
||||
const trExp = document.getElementById(`bulk-row-expanded-${id}`);
|
||||
const isExpanded = trExp ? trExp.style.display !== 'none' : false;
|
||||
const statusDot = document.getElementById(`status-dot-${id}`);
|
||||
const statusDotClass = statusDot ? statusDot.className : '';
|
||||
const statusDotTitle = statusDot ? statusDot.title : '';
|
||||
|
||||
data.push({
|
||||
state_id,
|
||||
title,
|
||||
queue_name,
|
||||
queue_id,
|
||||
type_id,
|
||||
owner_name,
|
||||
owner_id,
|
||||
responsible_name,
|
||||
responsible_id,
|
||||
customer_search,
|
||||
customer_id,
|
||||
customer_user_id,
|
||||
subject,
|
||||
body,
|
||||
priority_id,
|
||||
company_display,
|
||||
isExpanded,
|
||||
statusDotClass,
|
||||
statusDotTitle
|
||||
});
|
||||
});
|
||||
this.savedRows = data;
|
||||
},
|
||||
|
||||
setupAutocomplete(rowId, type, searchInputId, hiddenInputId, suggestionsDivId) {
|
||||
const input = document.getElementById(searchInputId);
|
||||
const hidden = document.getElementById(hiddenInputId);
|
||||
const suggestions = document.getElementById(suggestionsDivId);
|
||||
if (!input || !suggestions) return;
|
||||
|
||||
let debounce;
|
||||
|
||||
const adjustSuggestionsScroll = () => {
|
||||
setTimeout(() => {
|
||||
const wrapper = document.querySelector('.bulk-grid-wrapper');
|
||||
if (wrapper) {
|
||||
const rect = suggestions.getBoundingClientRect();
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
if (rect.right > wrapperRect.right) {
|
||||
wrapper.scrollBy({ left: rect.right - wrapperRect.right + 40, behavior: 'smooth' });
|
||||
} else if (rect.left < wrapperRect.left) {
|
||||
wrapper.scrollBy({ left: rect.left - wrapperRect.left - 40, behavior: 'smooth' });
|
||||
}
|
||||
if (rect.bottom > wrapperRect.bottom) {
|
||||
wrapper.scrollBy({ top: rect.bottom - wrapperRect.bottom + 40, behavior: 'smooth' });
|
||||
} else if (rect.top < wrapperRect.top) {
|
||||
wrapper.scrollBy({ top: rect.top - wrapperRect.top - 40, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const renderResults = (results) => {
|
||||
if (results.length === 0) {
|
||||
suggestions.innerHTML = '<div class="bulk-suggestions-item" style="color:var(--text-muted); cursor:default;">Nessun risultato</div>';
|
||||
suggestions.style.display = 'block';
|
||||
adjustSuggestionsScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
suggestions.innerHTML = results.map(r => {
|
||||
if (type === 'queue') {
|
||||
return `<div class="bulk-suggestions-item" data-id="${r.id}" data-name="${App.escapeHtml(r.name)}">${App.escapeHtml(r.name)}</div>`;
|
||||
} else if (type === 'owner' || type === 'responsible') {
|
||||
return `<div class="bulk-suggestions-item" data-id="${r.id}" data-name="${App.escapeHtml(r.first_name + ' ' + r.last_name)}"><strong>${App.escapeHtml(r.first_name)} ${App.escapeHtml(r.last_name)}</strong> <span style="font-size:0.75rem; color:var(--text-muted);">(${r.login})</span></div>`;
|
||||
} else if (type === 'customer') {
|
||||
return `<div class="bulk-suggestions-item" data-login="${App.escapeHtml(r.login)}" data-customer-id="${App.escapeHtml(r.customer_id || '')}" data-name="${App.escapeHtml(r.first_name + ' ' + r.last_name)}"><strong>${App.escapeHtml(r.first_name)} ${App.escapeHtml(r.last_name)}</strong> <span style="font-size:0.75rem; color:var(--text-muted);">(${r.login} | ${r.customer_id || '—'})</span></div>`;
|
||||
}
|
||||
}).join('');
|
||||
suggestions.style.display = 'block';
|
||||
adjustSuggestionsScroll();
|
||||
|
||||
// Bind clicks
|
||||
suggestions.querySelectorAll('.bulk-suggestions-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
if (type === 'queue' || type === 'owner' || type === 'responsible') {
|
||||
input.value = item.dataset.name;
|
||||
hidden.value = item.dataset.id;
|
||||
} else if (type === 'customer') {
|
||||
input.value = item.dataset.name;
|
||||
hidden.value = item.dataset.customerId || '';
|
||||
document.getElementById(`bulk-customer-user-id-${rowId}`).value = item.dataset.login;
|
||||
document.getElementById(`bulk-company-display-${rowId}`).value = item.dataset.customerId || '';
|
||||
}
|
||||
suggestions.style.display = 'none';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const showAllLocalOrQuery = async () => {
|
||||
if (type === 'queue') {
|
||||
renderResults(App.lookups.queues || []);
|
||||
} else if (type === 'owner' || type === 'responsible') {
|
||||
renderResults(App.lookups.users || []);
|
||||
} else if (type === 'customer') {
|
||||
try {
|
||||
const results = await App.api(`/api/customer-users/search?q=`);
|
||||
renderResults(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
const q = input.value.trim();
|
||||
|
||||
// Clear hidden values if user deletes text
|
||||
if (q.length === 0) {
|
||||
hidden.value = '';
|
||||
if (type === 'customer') {
|
||||
document.getElementById(`bulk-customer-user-id-${rowId}`).value = '';
|
||||
document.getElementById(`bulk-company-display-${rowId}`).value = '';
|
||||
}
|
||||
showAllLocalOrQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type !== 'queue' && type !== 'owner' && type !== 'responsible' && type !== 'customer' && q.length < 2) {
|
||||
suggestions.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Local filtering for fast performance on loaded lookups
|
||||
if (type === 'queue' || type === 'owner' || type === 'responsible') {
|
||||
const lowerQ = q.toLowerCase();
|
||||
let filtered = [];
|
||||
if (type === 'queue') {
|
||||
filtered = (App.lookups.queues || []).filter(r => r.name.toLowerCase().includes(lowerQ));
|
||||
} else {
|
||||
filtered = (App.lookups.users || []).filter(r =>
|
||||
r.first_name.toLowerCase().includes(lowerQ) ||
|
||||
r.last_name.toLowerCase().includes(lowerQ) ||
|
||||
r.login.toLowerCase().includes(lowerQ)
|
||||
);
|
||||
}
|
||||
renderResults(filtered);
|
||||
return;
|
||||
}
|
||||
|
||||
// API search for customer users
|
||||
debounce = setTimeout(async () => {
|
||||
try {
|
||||
const results = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
|
||||
renderResults(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Close suggestions clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target !== input && e.target !== suggestions) {
|
||||
suggestions.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Close suggestions on blur (tab navigation out of input)
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
suggestions.style.display = 'none';
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Empty input and show list on focus/click
|
||||
input.addEventListener('focus', () => {
|
||||
if (type === 'queue' || type === 'owner' || type === 'responsible' || type === 'customer') {
|
||||
input.value = '';
|
||||
hidden.value = '';
|
||||
if (type === 'customer') {
|
||||
document.getElementById(`bulk-customer-user-id-${rowId}`).value = '';
|
||||
document.getElementById(`bulk-company-display-${rowId}`).value = '';
|
||||
}
|
||||
showAllLocalOrQuery();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async submitBulk() {
|
||||
if (this.activeRequests) return;
|
||||
|
||||
const container = document.getElementById('bulk-rows-container');
|
||||
const rows = container.querySelectorAll('.bulk-row');
|
||||
if (rows.length === 0) {
|
||||
Toast.warning('Nessun ticket da creare');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation pass
|
||||
const ticketsToSend = [];
|
||||
let hasValidationError = false;
|
||||
|
||||
rows.forEach(row => {
|
||||
const id = row.id.replace('bulk-row-', '');
|
||||
const state_id = document.getElementById(`bulk-state-${id}`).value;
|
||||
const title = document.getElementById(`bulk-title-${id}`).value.trim();
|
||||
const queue_id = document.getElementById(`bulk-queue-${id}`).value;
|
||||
const queue_name = document.getElementById(`bulk-queue-search-${id}`).value;
|
||||
const type_id = document.getElementById(`bulk-type-${id}`).value;
|
||||
const ownerId = document.getElementById(`bulk-owner-${id}`).value;
|
||||
const responsibleId = document.getElementById(`bulk-responsible-${id}`).value;
|
||||
const customerId = document.getElementById(`bulk-customer-${id}`).value;
|
||||
const customerUserId = document.getElementById(`bulk-customer-user-id-${id}`).value;
|
||||
const customerSearch = document.getElementById(`bulk-customer-search-${id}`).value;
|
||||
const subject = document.getElementById(`bulk-subject-${id}`).value.trim();
|
||||
const body = document.getElementById(`bulk-body-${id}`).value.trim();
|
||||
const priority_id = document.getElementById(`bulk-priority-${id}`).value;
|
||||
|
||||
// Reset status indicators
|
||||
const dot = document.getElementById(`status-dot-${id}`);
|
||||
dot.className = 'row-status-dot';
|
||||
|
||||
if (!title) {
|
||||
Toast.warning(`Titolo obbligatorio alla riga ${id}`);
|
||||
document.getElementById(`bulk-title-${id}`).focus();
|
||||
hasValidationError = true;
|
||||
return;
|
||||
}
|
||||
if (!queue_id) {
|
||||
Toast.warning(`Seleziona una coda valida alla riga ${id}`);
|
||||
document.getElementById(`bulk-queue-search-${id}`).focus();
|
||||
hasValidationError = true;
|
||||
return;
|
||||
}
|
||||
if (!customerUserId && !customerId) {
|
||||
Toast.warning(`Seleziona un Utente Cliente valido alla riga ${id}`);
|
||||
document.getElementById(`bulk-customer-search-${id}`).focus();
|
||||
hasValidationError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ticketsToSend.push({
|
||||
id,
|
||||
payload: {
|
||||
title,
|
||||
queue_id: parseInt(queue_id),
|
||||
state_id: parseInt(state_id),
|
||||
priority_id: parseInt(priority_id),
|
||||
user_id: ownerId ? parseInt(ownerId) : undefined,
|
||||
responsible_user_id: responsibleId ? parseInt(responsibleId) : undefined,
|
||||
type_id: type_id ? parseInt(type_id) : undefined,
|
||||
customer_id: customerId || undefined,
|
||||
customer_user_id: customerUserId || undefined,
|
||||
subject: subject || undefined,
|
||||
body: body || undefined
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (hasValidationError) return;
|
||||
|
||||
// Send tickets one by one
|
||||
this.activeRequests = true;
|
||||
const submitBtn = document.getElementById('bulk-submit-btn');
|
||||
const origBtnHtml = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
|
||||
Toast.info(`Inizio invio di ${ticketsToSend.length} ticket...`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const ticket of ticketsToSend) {
|
||||
const dot = document.getElementById(`status-dot-${ticket.id}`);
|
||||
dot.className = 'row-status-dot pending';
|
||||
|
||||
try {
|
||||
submitBtn.innerHTML = `<div class="spinner" style="width:16px;height:16px;border-width:2px;"></div> Invio riga ${ticket.id}...`;
|
||||
|
||||
const result = await App.api('/api/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ticket.payload)
|
||||
});
|
||||
|
||||
dot.className = 'row-status-dot success';
|
||||
dot.title = `Successo: Ticket #${result.tn}`;
|
||||
successCount++;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Error creating bulk ticket row ${ticket.id}:`, err);
|
||||
dot.className = 'row-status-dot error';
|
||||
dot.title = `Errore: ${err.message}`;
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeRequests = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = origBtnHtml;
|
||||
|
||||
if (failCount === 0) {
|
||||
Toast.success(`Tutti i ${successCount} ticket sono stati creati con successo!`);
|
||||
this.savedRows = [];
|
||||
setTimeout(() => {
|
||||
window.location.hash = '#/tickets';
|
||||
}, 1500);
|
||||
} else {
|
||||
this.saveState();
|
||||
Toast.warning(`Invio completato: ${successCount} creati, ${failCount} falliti. Controlla i pallini colorati per i dettagli.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
+155
-45
@@ -3,6 +3,32 @@
|
||||
* Minimal, fast form for creating new tickets.
|
||||
*/
|
||||
const TicketCreateView = {
|
||||
savedState: null,
|
||||
|
||||
saveState() {
|
||||
const stateEl = document.getElementById('create-state');
|
||||
if (!stateEl) return;
|
||||
this.savedState = {
|
||||
state_id: stateEl.value,
|
||||
title: document.getElementById('create-title').value,
|
||||
queue_name: document.getElementById('create-queue-search').value,
|
||||
queue_id: document.getElementById('create-queue').value,
|
||||
type_id: document.getElementById('create-type').value,
|
||||
owner_name: document.getElementById('create-owner-search').value,
|
||||
owner_id: document.getElementById('create-owner').value,
|
||||
responsible_name: document.getElementById('create-responsible-search').value,
|
||||
responsible_id: document.getElementById('create-responsible').value,
|
||||
user_search: document.getElementById('create-user-search').value,
|
||||
customer_user_id: document.getElementById('create-customer-user-id').value,
|
||||
customer_id: document.getElementById('create-customer-id').value,
|
||||
company_search: document.getElementById('create-company-search').value,
|
||||
priority_id: document.getElementById('create-priority').value,
|
||||
subject: document.getElementById('create-subject').value,
|
||||
body: document.getElementById('create-body').value,
|
||||
isAdvancedVisible: document.getElementById('advanced-options').style.display !== 'none'
|
||||
};
|
||||
},
|
||||
|
||||
async render() {
|
||||
const container = document.getElementById('view-container');
|
||||
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento form...</p></div>';
|
||||
@@ -10,6 +36,57 @@ const TicketCreateView = {
|
||||
try {
|
||||
await App.ensureLookups();
|
||||
|
||||
// Check if we have incoming query parameters (e.g. from Teams integration)
|
||||
const hashParts = window.location.hash.split('?');
|
||||
const searchStr = hashParts.length > 1 ? hashParts[1] : window.location.search;
|
||||
const urlParams = new URLSearchParams(searchStr);
|
||||
const incomingBody = urlParams.get('body');
|
||||
const incomingCustomer = urlParams.get('customer');
|
||||
const incomingSubject = urlParams.get('subject');
|
||||
|
||||
if (incomingBody || incomingCustomer || incomingSubject) {
|
||||
let foundCustomer = null;
|
||||
if (incomingCustomer) {
|
||||
try {
|
||||
const results = await App.api(`/api/customer-users/search?q=${encodeURIComponent(incomingCustomer)}`);
|
||||
if (results && results.length > 0) {
|
||||
foundCustomer = results[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to search customer for Teams integration:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepopulate default active agent details
|
||||
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||
const agentName = activeAgent ? `${activeAgent.first_name} ${activeAgent.last_name}` : '';
|
||||
const agentId = activeAgent ? activeAgent.id : '';
|
||||
|
||||
this.savedState = {
|
||||
state_id: '1', // default state
|
||||
title: 'Segnalazione da Teams',
|
||||
queue_name: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].name : '',
|
||||
queue_id: App.lookups.queues && App.lookups.queues.length > 0 ? App.lookups.queues[0].id : '',
|
||||
type_id: '',
|
||||
owner_name: agentName,
|
||||
owner_id: agentId,
|
||||
responsible_name: agentName,
|
||||
responsible_id: agentId,
|
||||
user_search: foundCustomer ? `${foundCustomer.first_name} ${foundCustomer.last_name} <${foundCustomer.email}>` : (incomingCustomer || ''),
|
||||
customer_user_id: foundCustomer ? foundCustomer.login : (incomingCustomer || ''),
|
||||
customer_id: foundCustomer ? foundCustomer.customer_id : '',
|
||||
company_search: foundCustomer ? foundCustomer.customer_id : '',
|
||||
priority_id: '3',
|
||||
subject: incomingSubject || 'Messaggio da Microsoft Teams',
|
||||
body: incomingBody || '',
|
||||
isAdvancedVisible: false
|
||||
};
|
||||
|
||||
// Clean query parameters from URL to prevent duplicate inserts on reload
|
||||
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash.split('?')[0]);
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<a class="back-link" onclick="history.back()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
@@ -158,57 +235,83 @@ const TicketCreateView = {
|
||||
const advancedOptions = document.getElementById('advanced-options');
|
||||
const arrow = document.getElementById('advanced-arrow');
|
||||
|
||||
// Pre-populate default values asynchronously
|
||||
setTimeout(async () => {
|
||||
// 1. Owner & Responsible pre-population with active agent
|
||||
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||
if (activeAgent) {
|
||||
if (ownerSearchInput && ownerIdInput) {
|
||||
ownerSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||
ownerIdInput.value = activeAgent.id;
|
||||
}
|
||||
if (responsibleSearchInput && responsibleIdInput) {
|
||||
responsibleSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||
responsibleIdInput.value = activeAgent.id;
|
||||
}
|
||||
// Restore saved state if exists, otherwise load defaults asynchronously
|
||||
if (this.savedState) {
|
||||
document.getElementById('create-state').value = this.savedState.state_id;
|
||||
document.getElementById('create-title').value = this.savedState.title;
|
||||
document.getElementById('create-queue-search').value = this.savedState.queue_name;
|
||||
document.getElementById('create-queue').value = this.savedState.queue_id;
|
||||
if (document.getElementById('create-type')) {
|
||||
document.getElementById('create-type').value = this.savedState.type_id;
|
||||
}
|
||||
document.getElementById('create-owner-search').value = this.savedState.owner_name;
|
||||
document.getElementById('create-owner').value = this.savedState.owner_id;
|
||||
document.getElementById('create-responsible-search').value = this.savedState.responsible_name;
|
||||
document.getElementById('create-responsible').value = this.savedState.responsible_id;
|
||||
document.getElementById('create-user-search').value = this.savedState.user_search;
|
||||
document.getElementById('create-customer-user-id').value = this.savedState.customer_user_id;
|
||||
document.getElementById('create-customer-id').value = this.savedState.customer_id;
|
||||
document.getElementById('create-company-search').value = this.savedState.company_search;
|
||||
document.getElementById('create-priority').value = this.savedState.priority_id;
|
||||
document.getElementById('create-subject').value = this.savedState.subject;
|
||||
document.getElementById('create-body').value = this.savedState.body;
|
||||
|
||||
// 2. Customer User pre-population with first match
|
||||
try {
|
||||
const companies = await App.api('/api/customer-companies/search?q=cliente');
|
||||
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
||||
const defaultCompany = companies[0];
|
||||
customerIdInput.value = defaultCompany.customer_id;
|
||||
companySearchInput.value = defaultCompany.customer_id;
|
||||
|
||||
// Search users for this company
|
||||
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
|
||||
if (users.length > 0 && userSearchInput && customerUserIdInput) {
|
||||
const defaultUser = users[0];
|
||||
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
|
||||
customerUserIdInput.value = defaultUser.login;
|
||||
} else {
|
||||
// Fallback: use company name as customer user ID
|
||||
userSearchInput.value = defaultCompany.name;
|
||||
customerUserIdInput.value = defaultCompany.customer_id;
|
||||
if (this.savedState.isAdvancedVisible) {
|
||||
advancedOptions.style.display = 'grid';
|
||||
if (arrow) arrow.style.transform = 'rotate(90deg)';
|
||||
}
|
||||
} else {
|
||||
setTimeout(async () => {
|
||||
// 1. Owner & Responsible pre-population with active agent
|
||||
const currentAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
const activeAgent = (App.lookups.users || []).find(u => String(u.id) === String(currentAgentId));
|
||||
if (activeAgent) {
|
||||
if (ownerSearchInput && ownerIdInput) {
|
||||
ownerSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||
ownerIdInput.value = activeAgent.id;
|
||||
}
|
||||
if (responsibleSearchInput && responsibleIdInput) {
|
||||
responsibleSearchInput.value = `${activeAgent.first_name} ${activeAgent.last_name}`;
|
||||
responsibleIdInput.value = activeAgent.id;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error pre-populating defaults:', err);
|
||||
}
|
||||
|
||||
// 3. Queue pre-population
|
||||
try {
|
||||
const queues = await App.api('/api/queues/search?q=');
|
||||
if (queues.length > 0 && queueSearchInput && queueIdInput) {
|
||||
queueSearchInput.value = queues[0].name;
|
||||
queueIdInput.value = queues[0].id;
|
||||
// 2. Customer User pre-population with first match
|
||||
try {
|
||||
const companies = await App.api('/api/customer-companies/search?q=cliente');
|
||||
if (companies.length > 0 && customerIdInput && companySearchInput) {
|
||||
const defaultCompany = companies[0];
|
||||
customerIdInput.value = defaultCompany.customer_id;
|
||||
companySearchInput.value = defaultCompany.customer_id;
|
||||
|
||||
// Search users for this company
|
||||
const users = await App.api(`/api/customer-users/search?q=&customer_company_id=${encodeURIComponent(defaultCompany.customer_id)}`);
|
||||
if (users.length > 0 && userSearchInput && customerUserIdInput) {
|
||||
const defaultUser = users[0];
|
||||
userSearchInput.value = `${defaultUser.first_name} ${defaultUser.last_name}`;
|
||||
customerUserIdInput.value = defaultUser.login;
|
||||
} else {
|
||||
// Fallback: use company name as customer user ID
|
||||
userSearchInput.value = defaultCompany.name;
|
||||
customerUserIdInput.value = defaultCompany.customer_id;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error pre-populating defaults:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error pre-populating queues:', err);
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// 3. Queue pre-population
|
||||
try {
|
||||
const queues = await App.api('/api/queues/search?q=');
|
||||
if (queues.length > 0 && queueSearchInput && queueIdInput) {
|
||||
queueSearchInput.value = queues[0].name;
|
||||
queueIdInput.value = queues[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error pre-populating queues:', err);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Collapsible Options Toggle
|
||||
if (toggleBtn && advancedOptions && arrow) {
|
||||
@@ -491,6 +594,7 @@ const TicketCreateView = {
|
||||
});
|
||||
|
||||
Toast.success(`Ticket #${result.tn} creato!`);
|
||||
this.savedState = null; // Clear cached state on success
|
||||
|
||||
// Navigate to the new ticket
|
||||
window.location.hash = `#/tickets/${result.id}`;
|
||||
@@ -506,5 +610,11 @@ const TicketCreateView = {
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Save state on any input/change in the form
|
||||
document.querySelectorAll('.create-form input, .create-form select, .create-form textarea').forEach(el => {
|
||||
el.addEventListener('input', () => this.saveState());
|
||||
el.addEventListener('change', () => this.saveState());
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,12 @@ const TicketDetailView = {
|
||||
await App.ensureLookups();
|
||||
const data = await App.api(`/api/tickets/${id}`);
|
||||
const { ticket, articles } = data;
|
||||
const localISO = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const totalTime = articles.reduce((sum, a) => {
|
||||
const val = parseFloat(a.time_unit);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
@@ -131,12 +137,24 @@ const TicketDetailView = {
|
||||
<div class="article-header">
|
||||
<div class="article-sender">
|
||||
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</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>
|
||||
${a.channel_name ? `<span style="font-size:0.72rem;color:var(--text-muted);">via ${a.channel_name}</span>` : ''}
|
||||
</div>
|
||||
<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>` : ''}
|
||||
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
||||
|
||||
<div class="retrodate-container" data-article-id="${a.article_id}">
|
||||
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
||||
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
|
||||
<div class="retrodate-editor" style="display:none;">
|
||||
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
|
||||
<div class="retrodate-actions">
|
||||
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
|
||||
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
|
||||
@@ -162,7 +180,19 @@ const TicketDetailView = {
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Creato</span>
|
||||
<span class="meta-value">${App.formatDateTime(ticket.create_time)}</span>
|
||||
<span class="meta-value">
|
||||
<div class="retrodate-container" id="ticket-retrodate-container">
|
||||
<span id="ticket-date-text">${App.formatDateTime(ticket.create_time)}</span>
|
||||
<button class="retrodate-btn-edit" id="btn-edit-ticket-date" title="Retrodata Creazione Ticket">✏️</button>
|
||||
<div class="retrodate-editor" id="ticket-date-editor" style="display:none;">
|
||||
<input type="datetime-local" class="retrodate-input" id="input-ticket-date" value="${localISO(ticket.create_time)}" />
|
||||
<div class="retrodate-actions">
|
||||
<button class="retrodate-btn-action save" id="btn-save-ticket-date" title="Salva">✓</button>
|
||||
<button class="retrodate-btn-action cancel" id="btn-cancel-ticket-date" title="Annulla">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Modificato</span>
|
||||
@@ -338,5 +368,94 @@ const TicketDetailView = {
|
||||
noteSendBtn.innerHTML = 'Invia Nota';
|
||||
}
|
||||
});
|
||||
|
||||
// Retrodate Ticket Event Listeners
|
||||
const btnEditTicketDate = document.getElementById('btn-edit-ticket-date');
|
||||
const ticketDateEditor = document.getElementById('ticket-date-editor');
|
||||
const ticketDateText = document.getElementById('ticket-date-text');
|
||||
const btnSaveTicketDate = document.getElementById('btn-save-ticket-date');
|
||||
const btnCancelTicketDate = document.getElementById('btn-cancel-ticket-date');
|
||||
const inputTicketDate = document.getElementById('input-ticket-date');
|
||||
|
||||
if (btnEditTicketDate) {
|
||||
btnEditTicketDate.addEventListener('click', () => {
|
||||
btnEditTicketDate.style.display = 'none';
|
||||
ticketDateText.style.display = 'none';
|
||||
ticketDateEditor.style.display = 'flex';
|
||||
});
|
||||
|
||||
btnCancelTicketDate.addEventListener('click', () => {
|
||||
ticketDateEditor.style.display = 'none';
|
||||
btnEditTicketDate.style.display = 'inline-flex';
|
||||
ticketDateText.style.display = 'inline';
|
||||
});
|
||||
|
||||
btnSaveTicketDate.addEventListener('click', async () => {
|
||||
const val = inputTicketDate.value;
|
||||
if (!val) {
|
||||
Toast.warning('Scegli una data/ora valida.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
btnSaveTicketDate.disabled = true;
|
||||
await App.api(`/api/tickets/${this.ticketId}/retrodata-ticket`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ create_time: val })
|
||||
});
|
||||
Toast.success('Data creazione ticket aggiornata!');
|
||||
this.render(this.ticketId);
|
||||
} catch (err) {
|
||||
Toast.error('Errore retrodatazione ticket: ' + err.message);
|
||||
btnSaveTicketDate.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Retrodate Articles Event Listeners
|
||||
document.querySelectorAll('.retrodate-container[data-article-id]').forEach(container => {
|
||||
const articleId = container.dataset.articleId;
|
||||
const btnEdit = container.querySelector('.btn-edit-article-date');
|
||||
const editor = container.querySelector('.retrodate-editor');
|
||||
const timeText = container.querySelector('.article-time');
|
||||
const btnSave = container.querySelector('.btn-save-article-date');
|
||||
const btnCancel = container.querySelector('.btn-cancel-article-date');
|
||||
const input = container.querySelector('.input-article-date');
|
||||
|
||||
if (btnEdit) {
|
||||
btnEdit.addEventListener('click', () => {
|
||||
btnEdit.style.display = 'none';
|
||||
timeText.style.display = 'none';
|
||||
editor.style.display = 'flex';
|
||||
});
|
||||
|
||||
btnCancel.addEventListener('click', () => {
|
||||
editor.style.display = 'none';
|
||||
btnEdit.style.display = 'inline-flex';
|
||||
timeText.style.display = 'inline';
|
||||
});
|
||||
|
||||
btnSave.addEventListener('click', async () => {
|
||||
const val = input.value;
|
||||
if (!val) {
|
||||
Toast.warning('Scegli una data/ora valida.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
btnSave.disabled = true;
|
||||
await App.api(`/api/tickets/articles/${articleId}/retrodata-article`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ create_time: val })
|
||||
});
|
||||
Toast.success('Data creazione articolo aggiornata!');
|
||||
this.render(this.ticketId);
|
||||
} catch (err) {
|
||||
Toast.error('Errore retrodatazione articolo: ' + err.message);
|
||||
btnSave.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,11 +51,12 @@ const TicketListView = {
|
||||
const { total, page, per_page, total_pages } = data;
|
||||
|
||||
container.innerHTML = `
|
||||
${Filters.renderBar(App.lookups)}
|
||||
|
||||
<!-- Batch Actions Bar -->
|
||||
<div class="batch-bar" id="batch-bar">
|
||||
<span class="batch-count" id="batch-count">0 selezionati</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Stato</span>
|
||||
<select class="filter-select" id="batch-state">
|
||||
@@ -81,14 +82,13 @@ const TicketListView = {
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||
</div>
|
||||
|
||||
${Filters.renderBar(App.lookups)}
|
||||
|
||||
<!-- Ticket Table -->
|
||||
<div class="ticket-table-wrapper">
|
||||
<table class="ticket-table" id="ticket-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-cell">
|
||||
<input type="checkbox" id="select-all" title="Seleziona tutti" />
|
||||
</th>
|
||||
<th class="sortable ${this.sortBy === 'tn' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="tn">N°</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>
|
||||
@@ -96,27 +96,22 @@ const TicketListView = {
|
||||
<th class="sortable ${this.sortBy === 'queue' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="queue">Coda</th>
|
||||
<th>Owner</th>
|
||||
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||||
<th class="sortable ${this.sortBy === 'change_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="change_time">Modificato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tickets.length > 0 ? tickets.map(t => `
|
||||
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}">
|
||||
<td class="checkbox-cell" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="ticket-checkbox" value="${t.id}" ${this.selectedIds.has(String(t.id)) ? 'checked' : ''} />
|
||||
</td>
|
||||
<td><span class="ticket-tn">${t.tn}</span></td>
|
||||
<td class="ticket-title-cell">${App.escapeHtml(t.title || '(senza titolo)')}</td>
|
||||
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}" style="cursor:pointer;">
|
||||
<td><span class="ticket-tn"><a href="#/tickets/${t.id}" class="ticket-tn-link" onclick="event.stopPropagation()">${t.tn}</a></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><span class="badge badge-state" data-state-type="${(t.state_type || '').toLowerCase()}">${t.state_name}</span></td>
|
||||
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${t.priority_name}</span></td>
|
||||
<td><span class="badge badge-priority" data-priority="${App.priorityIndex(t.priority_name)}">${App.priorityIndex(t.priority_name)}</span></td>
|
||||
<td><span class="badge badge-queue">${t.queue_name}</span></td>
|
||||
<td style="color:var(--text-secondary);font-size:0.82rem;">${t.owner_first || ''} ${t.owner_last || ''}</td>
|
||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.create_time)}</td>
|
||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.change_time)}</td>
|
||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
||||
</tr>
|
||||
`).join('') : `
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<td colspan="7">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<div class="empty-state-text">Nessun ticket trovato</div>
|
||||
@@ -186,42 +181,22 @@ const TicketListView = {
|
||||
});
|
||||
});
|
||||
|
||||
// Row click → detail
|
||||
// Row click → Toggle selection
|
||||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.type === 'checkbox' || e.target.closest('.checkbox-cell')) return;
|
||||
window.location.hash = `#/tickets/${row.dataset.ticketId}`;
|
||||
});
|
||||
});
|
||||
|
||||
// Checkbox selection
|
||||
const selectAll = document.getElementById('select-all');
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', (e) => {
|
||||
const checkboxes = document.querySelectorAll('.ticket-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = e.target.checked;
|
||||
const id = cb.value;
|
||||
if (e.target.checked) {
|
||||
this.selectedIds.add(id);
|
||||
} else {
|
||||
this.selectedIds.delete(id);
|
||||
}
|
||||
cb.closest('tr').classList.toggle('selected', e.target.checked);
|
||||
});
|
||||
this.updateBatchBar();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
const id = e.target.value;
|
||||
if (e.target.checked) {
|
||||
// If click was on a link, let the browser perform navigation
|
||||
if (e.target.closest('.ticket-tn-link') || e.target.closest('.ticket-title-link')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = String(row.dataset.ticketId);
|
||||
if (this.selectedIds.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
row.classList.remove('selected');
|
||||
} else {
|
||||
this.selectedIds.add(id);
|
||||
} else {
|
||||
this.selectedIds.delete(id);
|
||||
row.classList.add('selected');
|
||||
}
|
||||
e.target.closest('tr').classList.toggle('selected', e.target.checked);
|
||||
this.updateBatchBar();
|
||||
});
|
||||
});
|
||||
@@ -232,17 +207,27 @@ const TicketListView = {
|
||||
batchApply.addEventListener('click', () => this.applyBatch());
|
||||
}
|
||||
|
||||
// Batch select all visible
|
||||
const batchSelectAll = document.getElementById('batch-select-all');
|
||||
if (batchSelectAll) {
|
||||
batchSelectAll.addEventListener('click', () => {
|
||||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(row => {
|
||||
const id = String(row.dataset.ticketId);
|
||||
this.selectedIds.add(id);
|
||||
row.classList.add('selected');
|
||||
});
|
||||
this.updateBatchBar();
|
||||
});
|
||||
}
|
||||
|
||||
// Batch cancel
|
||||
const batchCancel = document.getElementById('batch-cancel');
|
||||
if (batchCancel) {
|
||||
batchCancel.addEventListener('click', () => {
|
||||
this.selectedIds.clear();
|
||||
document.querySelectorAll('.ticket-checkbox').forEach(cb => {
|
||||
cb.checked = false;
|
||||
cb.closest('tr').classList.remove('selected');
|
||||
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
|
||||
tr.classList.remove('selected');
|
||||
});
|
||||
const selectAll = document.getElementById('select-all');
|
||||
if (selectAll) selectAll.checked = false;
|
||||
this.updateBatchBar();
|
||||
});
|
||||
}
|
||||
@@ -258,13 +243,9 @@ const TicketListView = {
|
||||
},
|
||||
|
||||
updateBatchBar() {
|
||||
const bar = document.getElementById('batch-bar');
|
||||
const count = document.getElementById('batch-count');
|
||||
if (this.selectedIds.size > 0) {
|
||||
bar.classList.add('visible');
|
||||
if (count) {
|
||||
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
||||
} else {
|
||||
bar.classList.remove('visible');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user