Prima importazione

This commit is contained in:
2026-07-05 10:55:40 +02:00
commit 457c3eacf6
23 changed files with 6384 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
/**
* Dashboard View
* Shows stats overview, distribution charts, and recent tickets.
*/
const DashboardView = {
async render() {
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento dashboard...</p></div>';
try {
const stats = await App.api('/api/dashboard/stats');
const maxByState = Math.max(...(stats.by_state || []).map(s => parseInt(s.count)), 1);
const maxByPriority = Math.max(...(stats.by_priority || []).map(s => parseInt(s.count)), 1);
const maxByQueue = Math.max(...(stats.by_queue || []).map(s => parseInt(s.count)), 1);
container.innerHTML = `
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card accent">
<div class="stat-label">Ticket Aperti</div>
<div class="stat-value">${stats.total_open}</div>
</div>
<div class="stat-card">
<div class="stat-label">Creati Oggi</div>
<div class="stat-value">${stats.created_today}</div>
</div>
<div class="stat-card">
<div class="stat-label">Creati Settimana</div>
<div class="stat-value">${stats.created_this_week}</div>
</div>
<div class="stat-card ${stats.escalated > 0 ? 'danger' : ''}">
<div class="stat-label">Escalated</div>
<div class="stat-value">${stats.escalated}</div>
</div>
</div>
<!-- Distributions -->
<div class="distribution-section">
<div class="card">
<div class="card-title">Per Stato</div>
${(stats.by_state || []).map(s => `
<div class="dist-bar-container">
<div class="dist-bar-header">
<span class="dist-bar-label">${s.state}</span>
<span class="dist-bar-value">${s.count}</span>
</div>
<div class="dist-bar-track">
<div class="dist-bar-fill" style="width: ${(parseInt(s.count) / maxByState * 100).toFixed(1)}%"></div>
</div>
</div>
`).join('')}
${(stats.by_state || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
</div>
<div class="card">
<div class="card-title">Per Priorità</div>
${(stats.by_priority || []).map((p, idx) => `
<div class="dist-bar-container">
<div class="dist-bar-header">
<span class="dist-bar-label">${p.priority}</span>
<span class="dist-bar-value">${p.count}</span>
</div>
<div class="dist-bar-track">
<div class="dist-bar-fill" style="width: ${(parseInt(p.count) / maxByPriority * 100).toFixed(1)}%; background: linear-gradient(90deg, var(--priority-${idx + 1}-text, var(--accent-primary)), var(--accent-secondary));"></div>
</div>
</div>
`).join('')}
${(stats.by_priority || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
</div>
<div class="card">
<div class="card-title">Per Coda (Top 10)</div>
${(stats.by_queue || []).map(q => `
<div class="dist-bar-container">
<div class="dist-bar-header">
<span class="dist-bar-label">${q.queue}</span>
<span class="dist-bar-value">${q.count}</span>
</div>
<div class="dist-bar-track">
<div class="dist-bar-fill" style="width: ${(parseInt(q.count) / maxByQueue * 100).toFixed(1)}%"></div>
</div>
</div>
`).join('')}
${(stats.by_queue || []).length === 0 ? '<p style="color:var(--text-tertiary);font-size:0.85rem;">Nessun dato</p>' : ''}
</div>
</div>
<!-- Recent Tickets -->
<div class="card">
<div class="card-title">Ticket Recenti</div>
${(stats.recent_tickets || []).length > 0 ? `
<table class="recent-tickets-table">
<thead>
<tr>
<th>Numero</th>
<th>Titolo</th>
<th>Stato</th>
<th>Priorità</th>
<th>Coda</th>
<th>Data</th>
</tr>
</thead>
<tbody>
${stats.recent_tickets.map(t => `
<tr onclick="window.location.hash='#/tickets/${t.id}'">
<td><span class="ticket-tn">${t.tn}</span></td>
<td class="ticket-title-cell">${App.escapeHtml(t.title || '')}</td>
<td><span class="badge badge-state" data-state-type="${(t.state_name || '').toLowerCase()}">${t.state_name}</span></td>
<td><span class="badge badge-priority" data-priority="${t.priority_name ? App.priorityIndex(t.priority_name) : 3}">${t.priority_name}</span></td>
<td><span class="badge badge-queue">${t.queue_name}</span></td>
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDate(t.create_time)}</td>
</tr>
`).join('')}
</tbody>
</table>
` : `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<div class="empty-state-text">Nessun ticket recente</div>
</div>
`}
</div>
`;
// Animate bars after render
requestAnimationFrame(() => {
document.querySelectorAll('.dist-bar-fill').forEach(bar => {
const w = bar.style.width;
bar.style.width = '0%';
requestAnimationFrame(() => { bar.style.width = w; });
});
});
// Update open ticket count in sidebar badge
const badge = document.getElementById('open-ticket-count');
if (badge && stats.total_open > 0) {
badge.textContent = stats.total_open;
}
} catch (err) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Errore caricamento dashboard</div>
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
</div>
`;
}
},
};
+510
View File
@@ -0,0 +1,510 @@
/**
* Ticket Create View
* Minimal, fast form for creating new tickets.
*/
const TicketCreateView = {
async render() {
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento form...</p></div>';
try {
await App.ensureLookups();
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>
Torna indietro
</a>
<div class="card create-form">
<div class="card-title" style="margin-bottom:var(--space-lg); font-size:0.9rem; display:flex; justify-content:space-between; align-items:center; width:100%; gap: var(--space-md); flex-wrap: wrap;">
<span>Crea Nuovo Ticket</span>
<select class="form-select" id="create-state" style="width:200px; padding: 6px 12px; height: 32px; font-size: 0.85rem; margin: 0; line-height: 1;">
${(App.lookups.states || []).map(s => {
const sel = s.type_name === 'new' ? 'selected' : '';
return `<option value="${s.id}" ${sel}>${s.name}</option>`;
}).join('')}
</select>
</div>
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Titolo <span class="required">*</span></label>
<input type="text" class="form-input" id="create-title" placeholder="Descrizione breve del problema" autofocus />
</div>
<div class="form-group" style="position:relative;">
<label class="form-label">Coda <span class="required">*</span></label>
<input type="text" class="form-input" id="create-queue-search" placeholder="Cerca coda..." autocomplete="off" />
<input type="hidden" id="create-queue" />
<div id="queue-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
</div>
<div class="form-group">
<label class="form-label">Tipo</label>
<select class="form-select" id="create-type">
<option value="">—</option>
${(App.lookups.types || []).map(t => `<option value="${t.id}">${t.name}</option>`).join('')}
</select>
</div>
<div class="form-group" style="position:relative;">
<label class="form-label">Owner (Proprietario)</label>
<input type="text" class="form-input" id="create-owner-search" placeholder="Cerca proprietario..." autocomplete="off" />
<input type="hidden" id="create-owner" />
<div id="owner-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
</div>
<div class="form-group" style="position:relative;">
<label class="form-label">Responsabile</label>
<input type="text" class="form-input" id="create-responsible-search" placeholder="Cerca responsabile..." autocomplete="off" />
<input type="hidden" id="create-responsible" />
<div id="responsible-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
</div>
<div class="form-group full-width" style="position:relative;">
<label class="form-label">Utente Cliente (Persona) <span class="required">*</span></label>
<input type="text" class="form-input" id="create-user-search" placeholder="Cerca utente (nome, email, login)..." autocomplete="off" />
<input type="hidden" id="create-customer-user-id" />
<div id="user-suggestions" class="autocomplete-suggestions" style="display:none;"></div>
</div>
<!-- Collapsible Advanced Options -->
<div class="full-width" id="advanced-options" style="display: none; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--space-md); padding: var(--space-md); background: rgba(255,255,255,0.02); border: 1px dashed var(--border-light); border-radius: var(--radius-md); margin-top: var(--space-md); margin-bottom: var(--space-md);">
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">Azienda Cliente (Società)</label>
<input type="text" class="form-input" id="create-company-search" readonly disabled placeholder="Auto-assegnata dal cliente" style="cursor: not-allowed; background: rgba(255,255,255,0.05); color: var(--text-secondary); margin-bottom:0;" />
<input type="hidden" id="create-customer-id" />
</div>
<div class="form-group" style="margin-bottom:0;">
<label class="form-label">Priorità</label>
<select class="form-select" id="create-priority" style="margin-bottom: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>
<div class="form-group full-width">
<label class="form-label">Oggetto</label>
<input type="text" class="form-input" id="create-subject" placeholder="Oggetto del primo articolo (opzionale)" />
</div>
<div class="form-group full-width">
<label class="form-label">Messaggio / Nota iniziale</label>
<textarea class="form-textarea" id="create-body" placeholder="Descrivi il problema in dettaglio..."></textarea>
</div>
</div>
<div class="form-actions" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<button type="button" class="btn btn-ghost btn-sm" id="toggle-advanced" style="display: flex; align-items: center; gap: var(--space-xs); padding: 6px 12px; height: auto; margin:0;">
<svg id="advanced-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px; transition: transform var(--transition-normal);"><path d="M9 18l6-6-6-6"/></svg>
Opzioni Avanzate
</button>
</div>
<div style="display:flex; gap:var(--space-sm);">
<button class="btn btn-ghost" onclick="history.back()">Annulla</button>
<button class="btn btn-primary" id="create-submit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
<path d="M12 5v14M5 12h14"/>
</svg>
Crea Ticket
</button>
</div>
</div>
</div>
`;
this.bindEvents();
} catch (err) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Errore caricamento form</div>
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
</div>
`;
}
},
bindEvents() {
const submitBtn = document.getElementById('create-submit');
const companySearchInput = document.getElementById('create-company-search');
const customerIdInput = document.getElementById('create-customer-id');
const userSearchInput = document.getElementById('create-user-search');
const userSuggestionsDiv = document.getElementById('user-suggestions');
const customerUserIdInput = document.getElementById('create-customer-user-id');
const ownerSearchInput = document.getElementById('create-owner-search');
const ownerSuggestionsDiv = document.getElementById('owner-suggestions');
const ownerIdInput = document.getElementById('create-owner');
const responsibleSearchInput = document.getElementById('create-responsible-search');
const responsibleSuggestionsDiv = document.getElementById('responsible-suggestions');
const responsibleIdInput = document.getElementById('create-responsible');
const queueSearchInput = document.getElementById('create-queue-search');
const queueSuggestionsDiv = document.getElementById('queue-suggestions');
const queueIdInput = document.getElementById('create-queue');
const stateIdInput = document.getElementById('create-state');
const toggleBtn = document.getElementById('toggle-advanced');
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;
}
}
// 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);
}
// 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) {
toggleBtn.addEventListener('click', () => {
const isHidden = advancedOptions.style.display === 'none';
advancedOptions.style.display = isHidden ? 'grid' : 'none';
arrow.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
});
}
// User Autocomplete
let userDebounce;
if (userSearchInput) {
userSearchInput.addEventListener('input', () => {
clearTimeout(userDebounce);
const q = userSearchInput.value.trim();
if (q.length < 2) {
userSuggestionsDiv.style.display = 'none';
customerUserIdInput.value = '';
return;
}
userDebounce = setTimeout(async () => {
try {
const users = await App.api(`/api/customer-users/search?q=${encodeURIComponent(q)}`);
if (users.length === 0) {
userSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun utente trovato</div>';
userSuggestionsDiv.style.display = 'block';
return;
}
userSuggestionsDiv.innerHTML = users.map(u => `
<div class="autocomplete-suggestion-item" data-login="${App.escapeHtml(u.login)}" data-customer-id="${App.escapeHtml(u.customer_id || '')}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)} | Azienda: ${App.escapeHtml(u.customer_id || '—')})</span>
</div>
`).join('');
userSuggestionsDiv.style.display = 'block';
// Bind click
userSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.login) {
item.addEventListener('click', () => {
userSearchInput.value = item.dataset.name;
customerUserIdInput.value = item.dataset.login;
userSuggestionsDiv.style.display = 'none';
// Auto-fill company inside Advanced Options
if (item.dataset.customerId) {
customerIdInput.value = item.dataset.customerId;
companySearchInput.value = item.dataset.customerId;
}
});
}
});
} catch (err) {
console.error(err);
}
}, 300);
});
}
// Owner Autocomplete (dynamic backend search)
let ownerDebounce;
if (ownerSearchInput) {
ownerSearchInput.addEventListener('input', () => {
clearTimeout(ownerDebounce);
const q = ownerSearchInput.value.trim();
// Do not block empty query to allow all agent results on focus
ownerDebounce = setTimeout(async () => {
try {
const agents = await App.api(`/api/agents/search?q=${encodeURIComponent(q)}`);
if (agents.length === 0) {
ownerSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun agente trovato</div>';
ownerSuggestionsDiv.style.display = 'block';
return;
}
ownerSuggestionsDiv.innerHTML = agents.map(u => `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(u.id)}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)})</span>
</div>
`).join('');
ownerSuggestionsDiv.style.display = 'block';
// Bind click
ownerSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
item.addEventListener('click', () => {
ownerSearchInput.value = item.dataset.name;
ownerIdInput.value = item.dataset.id;
ownerSuggestionsDiv.style.display = 'none';
});
}
});
} catch (err) {
console.error(err);
}
}, 300);
});
ownerSearchInput.addEventListener('focus', () => {
ownerSearchInput.value = '';
ownerIdInput.value = '';
ownerSearchInput.dispatchEvent(new Event('input'));
});
}
// Responsible Autocomplete (dynamic backend search)
let responsibleDebounce;
if (responsibleSearchInput) {
responsibleSearchInput.addEventListener('input', () => {
clearTimeout(responsibleDebounce);
const q = responsibleSearchInput.value.trim();
// Do not block empty query to allow all agent results on focus
responsibleDebounce = setTimeout(async () => {
try {
const agents = await App.api(`/api/agents/search?q=${encodeURIComponent(q)}`);
if (agents.length === 0) {
responsibleSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessun agente trovato</div>';
responsibleSuggestionsDiv.style.display = 'block';
return;
}
responsibleSuggestionsDiv.innerHTML = agents.map(u => `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(u.id)}" data-name="${App.escapeHtml(u.first_name + ' ' + u.last_name)}">
<strong>${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}</strong>
<span style="font-size:0.75rem; color:var(--text-muted); margin-left:var(--space-xs); font-weight:normal;">(Login: ${App.escapeHtml(u.login)})</span>
</div>
`).join('');
responsibleSuggestionsDiv.style.display = 'block';
// Bind click
responsibleSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
item.addEventListener('click', () => {
responsibleSearchInput.value = item.dataset.name;
responsibleIdInput.value = item.dataset.id;
responsibleSuggestionsDiv.style.display = 'none';
});
}
});
} catch (err) {
console.error(err);
}
}, 300);
});
responsibleSearchInput.addEventListener('focus', () => {
responsibleSearchInput.value = '';
responsibleIdInput.value = '';
responsibleSearchInput.dispatchEvent(new Event('input'));
});
}
// Queue Autocomplete
let queueDebounce;
if (queueSearchInput) {
queueSearchInput.addEventListener('input', () => {
clearTimeout(queueDebounce);
const q = queueSearchInput.value.trim();
queueDebounce = setTimeout(async () => {
try {
const queues = await App.api(`/api/queues/search?q=${encodeURIComponent(q)}`);
if (queues.length === 0) {
queueSuggestionsDiv.innerHTML = '<div class="autocomplete-suggestion-item" style="color:var(--text-muted); cursor:default;">Nessuna coda trovata</div>';
queueSuggestionsDiv.style.display = 'block';
return;
}
queueSuggestionsDiv.innerHTML = queues.map(q => `
<div class="autocomplete-suggestion-item" data-id="${App.escapeHtml(q.id)}" data-name="${App.escapeHtml(q.name)}">
<strong>${App.escapeHtml(q.name)}</strong>
</div>
`).join('');
queueSuggestionsDiv.style.display = 'block';
// Bind click
queueSuggestionsDiv.querySelectorAll('.autocomplete-suggestion-item').forEach(item => {
if (item.dataset.id) {
item.addEventListener('click', () => {
queueSearchInput.value = item.dataset.name;
queueIdInput.value = item.dataset.id;
queueSuggestionsDiv.style.display = 'none';
});
}
});
} catch (err) {
console.error(err);
}
}, 150);
});
queueSearchInput.addEventListener('focus', () => {
queueSearchInput.value = '';
queueIdInput.value = '';
queueSearchInput.dispatchEvent(new Event('input'));
});
}
// Close suggestions on click outside
document.addEventListener('click', (e) => {
if (userSearchInput && e.target !== userSearchInput && e.target !== userSuggestionsDiv) {
userSuggestionsDiv.style.display = 'none';
}
if (ownerSearchInput && e.target !== ownerSearchInput && e.target !== ownerSuggestionsDiv) {
ownerSuggestionsDiv.style.display = 'none';
}
if (responsibleSearchInput && e.target !== responsibleSearchInput && e.target !== responsibleSuggestionsDiv) {
responsibleSuggestionsDiv.style.display = 'none';
}
if (queueSearchInput && e.target !== queueSearchInput && e.target !== queueSuggestionsDiv) {
queueSuggestionsDiv.style.display = 'none';
}
});
submitBtn.addEventListener('click', async () => {
const title = document.getElementById('create-title').value.trim();
const queue_id = queueIdInput.value;
const state_id = stateIdInput.value;
const priority_id = document.getElementById('create-priority').value;
const type_id = document.getElementById('create-type')?.value;
const customerId = customerIdInput.value;
let customerUserId = customerUserIdInput.value;
const ownerId = ownerIdInput.value;
const responsibleId = responsibleIdInput.value;
// Validation
if (!title) {
Toast.warning('Il titolo è obbligatorio');
document.getElementById('create-title').focus();
return;
}
if (!queue_id) {
Toast.warning('Seleziona una coda');
queueSearchInput.focus();
return;
}
if (!state_id) {
Toast.warning('Seleziona uno stato');
document.getElementById('create-state').focus();
return;
}
if (!customerId && !customerUserId) {
Toast.warning('Seleziona un Utente Cliente');
userSearchInput.focus();
return;
}
// Company fallback if no individual user selected
if (customerId && !customerUserId) {
customerUserId = customerId;
}
const 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: document.getElementById('create-subject').value.trim() || undefined,
body: document.getElementById('create-body').value.trim() || undefined,
};
try {
submitBtn.disabled = true;
submitBtn.innerHTML = '<div class="spinner" style="width:16px;height:16px;border-width:2px;"></div> Creazione...';
const result = await App.api('/api/tickets', {
method: 'POST',
body: JSON.stringify(payload),
});
Toast.success(`Ticket #${result.tn} creato!`);
// Navigate to the new ticket
window.location.hash = `#/tickets/${result.id}`;
} catch (err) {
Toast.error('Errore creazione: ' + err.message);
submitBtn.disabled = false;
submitBtn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;">
<path d="M12 5v14M5 12h14"/>
</svg>
Crea Ticket
`;
}
});
},
};
+342
View File
@@ -0,0 +1,342 @@
/**
* Ticket Detail View
* Shows full ticket info with quick-edit dropdowns, article timeline, and add-note form.
*/
const TicketDetailView = {
ticketId: null,
originalValues: {},
async render(id) {
this.ticketId = id;
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
try {
await App.ensureLookups();
const data = await App.api(`/api/tickets/${id}`);
const { ticket, articles } = data;
const totalTime = articles.reduce((sum, a) => {
const val = parseFloat(a.time_unit);
return sum + (isNaN(val) ? 0 : val);
}, 0);
this.originalValues = {
ticket_state_id: ticket.ticket_state_id,
ticket_priority_id: ticket.ticket_priority_id,
queue_id: ticket.queue_id,
user_id: ticket.user_id,
type_id: ticket.type_id,
};
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>
Torna alla lista
</a>
<div class="ticket-detail">
<div class="ticket-detail-main">
<!-- Header -->
<div class="card">
<div class="ticket-header">
<div class="ticket-header-info">
<div class="ticket-number">#${ticket.tn}</div>
<h2 class="ticket-detail-title">${App.escapeHtml(ticket.title || '(senza titolo)')}</h2>
<div class="ticket-meta-badges">
<span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span>
<span class="badge badge-priority" data-priority="${App.priorityIndex(ticket.priority_name)}">${ticket.priority_name}</span>
<span class="badge badge-queue">${ticket.queue_name}</span>
${ticket.lock_name === 'lock' ? '<span class="badge" style="background:var(--warning-bg);color:var(--warning);">🔒 Bloccato</span>' : ''}
</div>
</div>
</div>
</div>
<!-- Quick Edit -->
<div class="card">
<div class="card-title">Modifica Rapida</div>
<div class="quick-edit">
<div class="quick-edit-field">
<label class="quick-edit-label">Stato</label>
<select class="quick-edit-select" id="qe-state" data-field="ticket_state_id">
${(App.lookups.states || []).map(s =>
`<option value="${s.id}" ${s.id === ticket.ticket_state_id ? 'selected' : ''}>${s.name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Priorità</label>
<select class="quick-edit-select" id="qe-priority" data-field="ticket_priority_id">
${(App.lookups.priorities || []).map(p =>
`<option value="${p.id}" ${p.id === ticket.ticket_priority_id ? 'selected' : ''}>${p.name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Coda</label>
<select class="quick-edit-select" id="qe-queue" data-field="queue_id">
${(App.lookups.queues || []).map(q =>
`<option value="${q.id}" ${q.id === ticket.queue_id ? 'selected' : ''}>${q.name}</option>`
).join('')}
</select>
</div>
<div class="quick-edit-field">
<label class="quick-edit-label">Owner</label>
<select class="quick-edit-select" id="qe-owner" data-field="user_id">
${(App.lookups.users || []).map(u =>
`<option value="${u.id}" ${u.id === ticket.user_id ? 'selected' : ''}>${u.first_name} ${u.last_name}</option>`
).join('')}
</select>
</div>
${(App.lookups.types || []).length > 0 ? `
<div class="quick-edit-field">
<label class="quick-edit-label">Tipo</label>
<select class="quick-edit-select" id="qe-type" data-field="type_id">
<option value="">—</option>
${App.lookups.types.map(t =>
`<option value="${t.id}" ${t.id === ticket.type_id ? 'selected' : ''}>${t.name}</option>`
).join('')}
</select>
</div>
` : ''}
</div>
<div style="margin-top:var(--space-md);display:flex;gap:var(--space-sm);justify-content:flex-end;">
<button class="btn btn-ghost btn-sm" id="qe-reset">Reset</button>
<button class="btn btn-primary btn-sm" id="qe-save" disabled>Salva Modifiche</button>
</div>
</div>
<!-- Add Note Form -->
<div class="add-note-form" style="margin-bottom: var(--space-lg);">
<div class="card-title">Aggiungi Nota</div>
<div style="display:flex; gap:var(--space-md); margin-bottom:var(--space-md);">
<input type="text" class="note-subject-input" id="note-subject" placeholder="Oggetto (opzionale)" style="flex:1; margin-bottom:0;" />
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0;" />
</div>
<textarea class="note-textarea" id="note-body" placeholder="Scrivi una nota interna..."></textarea>
<div style="display:flex;gap:var(--space-sm);justify-content:flex-end;">
<button class="btn btn-primary btn-sm" id="note-send">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
Invia Nota
</button>
</div>
</div>
<!-- Articles Timeline -->
<div>
<div class="card-title" style="margin-bottom:var(--space-md);">Articoli & Note (${articles.length})</div>
<div class="articles-timeline">
${articles.length > 0 ? articles.map(a => `
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
<div class="article-header">
<div class="article-sender">
<span class="article-sender-badge ${(a.sender_type || 'system').toLowerCase()}">${a.sender_type || 'System'}</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>
</div>
${a.a_subject ? `<div class="article-subject">${App.escapeHtml(a.a_subject)}</div>` : ''}
<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>
</div>
`).join('') : `
<div class="empty-state" style="padding:var(--space-lg);">
<div class="empty-state-icon">💬</div>
<div class="empty-state-text">Nessun articolo</div>
</div>
`}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="ticket-sidebar">
<div class="sidebar-panel">
<div class="sidebar-panel-title">Dettagli</div>
<div class="meta-row">
<span class="meta-label">Numero</span>
<span class="meta-value" style="font-family:monospace;">${ticket.tn}</span>
</div>
<div class="meta-row">
<span class="meta-label">Creato</span>
<span class="meta-value">${App.formatDateTime(ticket.create_time)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Modificato</span>
<span class="meta-value">${App.formatDateTime(ticket.change_time)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Lock</span>
<span class="meta-value">${ticket.lock_name || 'unlock'}</span>
</div>
${ticket.type_name ? `
<div class="meta-row">
<span class="meta-label">Tipo</span>
<span class="meta-value">${ticket.type_name}</span>
</div>
` : ''}
${ticket.responsible_first ? `
<div class="meta-row">
<span class="meta-label">Responsabile</span>
<span class="meta-value">${ticket.responsible_first} ${ticket.responsible_last}</span>
</div>
` : ''}
${totalTime > 0 ? `
<div class="meta-row">
<span class="meta-label">Tempo Totale</span>
<span class="meta-value" style="font-weight:bold;color:var(--info);">⏱ ${totalTime} min</span>
</div>
` : ''}
</div>
${ticket.customer_user_id || ticket.customer_id ? `
<div class="sidebar-panel">
<div class="sidebar-panel-title">Cliente</div>
${ticket.customer_first ? `
<div class="meta-row">
<span class="meta-label">Nome</span>
<span class="meta-value">${ticket.customer_first} ${ticket.customer_last}</span>
</div>
` : ''}
${ticket.customer_email ? `
<div class="meta-row">
<span class="meta-label">Email</span>
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_email}</span>
</div>
` : ''}
${ticket.customer_phone ? `
<div class="meta-row">
<span class="meta-label">Telefono</span>
<span class="meta-value">${ticket.customer_phone}</span>
</div>
` : ''}
${ticket.customer_id ? `
<div class="meta-row">
<span class="meta-label">Customer ID</span>
<span class="meta-value" style="font-size:0.78rem;">${ticket.customer_id}</span>
</div>
` : ''}
</div>
` : ''}
${ticket.escalation_time > 0 ? `
<div class="sidebar-panel" style="border-color: rgba(239,68,68,0.3);">
<div class="sidebar-panel-title" style="color:var(--error);">⚠ Escalation</div>
<div class="meta-row">
<span class="meta-label">Tempo</span>
<span class="meta-value" style="color:var(--error);">${new Date(ticket.escalation_time * 1000).toLocaleString('it-IT')}</span>
</div>
</div>
` : ''}
</div>
</div>
`;
this.bindEvents();
} catch (err) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Errore caricamento ticket</div>
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
<button class="btn btn-ghost" style="margin-top:var(--space-md);" onclick="history.back()">Torna indietro</button>
</div>
`;
}
},
bindEvents() {
// Quick-edit change detection
const selects = document.querySelectorAll('.quick-edit-select');
const saveBtn = document.getElementById('qe-save');
const resetBtn = document.getElementById('qe-reset');
const checkChanges = () => {
let hasChanges = false;
selects.forEach(sel => {
const field = sel.dataset.field;
const original = String(this.originalValues[field] || '');
const current = sel.value;
const changed = current !== original;
sel.classList.toggle('changed', changed);
if (changed) hasChanges = true;
});
saveBtn.disabled = !hasChanges;
};
selects.forEach(sel => sel.addEventListener('change', checkChanges));
// Reset quick-edit
resetBtn.addEventListener('click', () => {
selects.forEach(sel => {
sel.value = this.originalValues[sel.dataset.field] || '';
sel.classList.remove('changed');
});
saveBtn.disabled = true;
});
// Save quick-edit
saveBtn.addEventListener('click', async () => {
const updates = {};
selects.forEach(sel => {
const field = sel.dataset.field;
const val = sel.value ? parseInt(sel.value) : null;
if (val !== null && val !== this.originalValues[field]) {
updates[field] = val;
}
});
if (Object.keys(updates).length === 0) return;
try {
saveBtn.disabled = true;
saveBtn.textContent = 'Salvando...';
const res = await App.api(`/api/tickets/${this.ticketId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
Toast.success(res.message || 'Ticket aggiornato!');
// Refresh the view
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore: ' + err.message);
saveBtn.disabled = false;
saveBtn.textContent = 'Salva Modifiche';
}
});
// Send note
const noteSendBtn = document.getElementById('note-send');
noteSendBtn.addEventListener('click', async () => {
const body = document.getElementById('note-body').value.trim();
const subject = document.getElementById('note-subject').value.trim();
const time_unit = document.getElementById('note-time-units').value.trim();
if (!body) {
Toast.warning('Scrivi qualcosa prima di inviare');
return;
}
try {
noteSendBtn.disabled = true;
noteSendBtn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;"></div> Invio...';
const res = await App.api(`/api/tickets/${this.ticketId}/articles`, {
method: 'POST',
body: JSON.stringify({ subject, body, time_unit }),
});
Toast.success(res.message || 'Nota aggiunta!');
this.render(this.ticketId);
} catch (err) {
Toast.error('Errore: ' + err.message);
noteSendBtn.disabled = false;
noteSendBtn.innerHTML = 'Invia Nota';
}
});
},
};
+303
View File
@@ -0,0 +1,303 @@
/**
* Ticket List View
* Full-featured ticket list with filters, sorting, batch actions, and pagination.
*/
const TicketListView = {
currentPage: 1,
perPage: 50,
sortBy: 'create_time',
sortDir: 'DESC',
selectedIds: new Set(),
searchTimeout: null,
async render() {
const container = document.getElementById('view-container');
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Caricamento ticket...</p></div>';
try {
// Fetch lookups for filter dropdowns
await App.ensureLookups();
// Build query params
const params = Filters.toQueryParams();
params.set('page', this.currentPage);
params.set('per_page', this.perPage);
params.set('sort_by', this.sortBy);
params.set('sort_dir', this.sortDir);
const searchInput = document.getElementById('global-search');
if (searchInput && searchInput.value.trim()) {
params.set('search', searchInput.value.trim());
}
const data = await App.api(`/api/tickets?${params.toString()}`);
this.renderContent(container, data);
this.bindEvents(data);
} catch (err) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<div class="empty-state-text">Errore caricamento ticket</div>
<div class="empty-state-sub">${App.escapeHtml(err.message)}</div>
</div>
`;
}
},
renderContent(container, data) {
const tickets = data.tickets || [];
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 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">
<span class="filter-label">Coda</span>
<select class="filter-select" id="batch-queue">
<option value="">—</option>
${(App.lookups.queues || []).map(q => `<option value="${q.id}">${q.name}</option>`).join('')}
</select>
</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>
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
</div>
<!-- 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>
<th class="sortable ${this.sortBy === 'priority' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="priority">Priorità</th>
<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>
<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-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>
</tr>
`).join('') : `
<tr>
<td colspan="9">
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<div class="empty-state-text">Nessun ticket trovato</div>
<div class="empty-state-sub">Prova a cambiare i filtri o crea un nuovo ticket.</div>
</div>
</td>
</tr>
`}
</tbody>
</table>
</div>
<!-- Pagination -->
${total_pages > 1 ? `
<div class="pagination">
<div class="pagination-info">
Mostrando ${((page - 1) * per_page) + 1}${Math.min(page * per_page, total)} di ${total} ticket
</div>
<div class="pagination-controls">
<button class="pagination-btn" data-page="1" ${page <= 1 ? 'disabled' : ''}>«</button>
<button class="pagination-btn" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}></button>
${this.renderPageButtons(page, total_pages)}
<button class="pagination-btn" data-page="${page + 1}" ${page >= total_pages ? 'disabled' : ''}></button>
<button class="pagination-btn" data-page="${total_pages}" ${page >= total_pages ? 'disabled' : ''}>»</button>
</div>
</div>
` : `
<div class="pagination">
<div class="pagination-info">${total} ticket totali</div>
<div></div>
</div>
`}
`;
},
renderPageButtons(current, total) {
const pages = [];
const start = Math.max(1, current - 2);
const end = Math.min(total, current + 2);
for (let i = start; i <= end; i++) {
pages.push(`<button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}">${i}</button>`);
}
return pages.join('');
},
bindEvents(data) {
// Filter events
Filters.bindEvents(() => {
this.currentPage = 1;
this.selectedIds.clear();
this.render();
});
// Sort events
document.querySelectorAll('.ticket-table th.sortable').forEach(th => {
th.addEventListener('click', () => {
const sortKey = th.dataset.sort;
if (this.sortBy === sortKey) {
this.sortDir = this.sortDir === 'ASC' ? 'DESC' : 'ASC';
} else {
this.sortBy = sortKey;
this.sortDir = 'DESC';
}
this.currentPage = 1;
this.render();
});
});
// Row click → detail
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) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
e.target.closest('tr').classList.toggle('selected', e.target.checked);
this.updateBatchBar();
});
});
// Batch apply
const batchApply = document.getElementById('batch-apply');
if (batchApply) {
batchApply.addEventListener('click', () => this.applyBatch());
}
// 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');
});
const selectAll = document.getElementById('select-all');
if (selectAll) selectAll.checked = false;
this.updateBatchBar();
});
}
// Pagination
document.querySelectorAll('.pagination-btn[data-page]').forEach(btn => {
btn.addEventListener('click', () => {
this.currentPage = parseInt(btn.dataset.page);
this.selectedIds.clear();
this.render();
});
});
},
updateBatchBar() {
const bar = document.getElementById('batch-bar');
const count = document.getElementById('batch-count');
if (this.selectedIds.size > 0) {
bar.classList.add('visible');
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
} else {
bar.classList.remove('visible');
}
},
async applyBatch() {
if (this.selectedIds.size === 0) return;
const updates = {};
const batchState = document.getElementById('batch-state')?.value;
const batchQueue = document.getElementById('batch-queue')?.value;
const batchOwner = document.getElementById('batch-owner')?.value;
if (batchState) updates.ticket_state_id = parseInt(batchState);
if (batchQueue) updates.queue_id = parseInt(batchQueue);
if (batchOwner) updates.user_id = parseInt(batchOwner);
if (Object.keys(updates).length === 0) {
Toast.warning('Seleziona almeno un campo da modificare');
return;
}
try {
const res = await App.api('/api/tickets/batch/update', {
method: 'PATCH',
body: JSON.stringify({
ticket_ids: Array.from(this.selectedIds),
updates,
}),
});
Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`);
this.selectedIds.clear();
this.render();
} catch (err) {
Toast.error('Errore aggiornamento batch: ' + err.message);
}
},
};