feat: contatore tempo allocato, pagina miei ticket, unione ticket (da db)
This commit is contained in:
+192
-3
@@ -11,9 +11,14 @@ const App = {
|
||||
types: [],
|
||||
},
|
||||
lookupsLoaded: false,
|
||||
demotivationalPhrases: [],
|
||||
motivationalPhrases: [],
|
||||
|
||||
/** Initialize the application */
|
||||
init() {
|
||||
this.initTheme();
|
||||
this.loadDemotivationalPhrases();
|
||||
this.loadMotivationalPhrases();
|
||||
Toast.init();
|
||||
|
||||
// Hash-based SPA router
|
||||
@@ -21,9 +26,17 @@ const App = {
|
||||
|
||||
// Global search
|
||||
const searchInput = document.getElementById('global-search');
|
||||
const searchClearBtn = document.getElementById('global-search-clear');
|
||||
if (searchInput) {
|
||||
const toggleClearBtn = () => {
|
||||
if (searchClearBtn) {
|
||||
searchClearBtn.style.display = searchInput.value.trim() ? 'flex' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
let timeout;
|
||||
searchInput.addEventListener('input', () => {
|
||||
toggleClearBtn();
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
const hash = window.location.hash;
|
||||
@@ -49,6 +62,24 @@ const App = {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (searchClearBtn) {
|
||||
searchClearBtn.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
searchClearBtn.style.display = 'none';
|
||||
searchInput.focus();
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/tickets') && !hash.includes('/new') && !hash.match(/#\/tickets\/\d+/)) {
|
||||
TicketListView.currentPage = 1;
|
||||
TicketListView.render();
|
||||
} else {
|
||||
window.location.hash = '#/tickets';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial state of clear button
|
||||
toggleClearBtn();
|
||||
}
|
||||
|
||||
// Check DB connection
|
||||
@@ -92,6 +123,11 @@ const App = {
|
||||
titleEl.textContent = 'Ticket';
|
||||
TicketListView.render();
|
||||
|
||||
} else if (hash === '#/tickets/my') {
|
||||
document.getElementById('nav-tickets-my')?.classList.add('active');
|
||||
titleEl.textContent = 'Ticket a mio carico';
|
||||
TicketListView.render();
|
||||
|
||||
} else if (hash === '#/tickets/new') {
|
||||
document.getElementById('nav-new-ticket')?.classList.add('active');
|
||||
titleEl.textContent = 'Nuovo Ticket';
|
||||
@@ -154,15 +190,16 @@ const App = {
|
||||
}
|
||||
|
||||
try {
|
||||
const [queues, states, priorities, users, types] = await Promise.all([
|
||||
const [queues, states, priorities, users, types, config] = await Promise.all([
|
||||
this.api('/api/queues'),
|
||||
this.api('/api/states'),
|
||||
this.api('/api/priorities'),
|
||||
this.api('/api/users'),
|
||||
this.api('/api/types'),
|
||||
this.api('/api/config').catch(() => ({ defaultAgentLogin: '' })),
|
||||
]);
|
||||
|
||||
this.lookups = { queues, states, priorities, users, types };
|
||||
this.lookups = { queues, states, priorities, users, types, config };
|
||||
localStorage.setItem('otrs_lookups', JSON.stringify(this.lookups));
|
||||
this.lookupsLoaded = true;
|
||||
} catch (err) {
|
||||
@@ -266,10 +303,18 @@ const App = {
|
||||
`<option value="${u.id}">${u.first_name} ${u.last_name} (${u.login})</option>`
|
||||
).join('');
|
||||
|
||||
// Load saved agent ID or default to the first available
|
||||
// Load saved agent ID or default to the config-specified default agent, or first available
|
||||
const savedAgentId = localStorage.getItem('activeAgentId');
|
||||
const defaultAgentLogin = this.lookups.config ? this.lookups.config.defaultAgentLogin : null;
|
||||
const defaultAgent = defaultAgentLogin
|
||||
? (this.lookups.users || []).find(u => u.login === defaultAgentLogin)
|
||||
: null;
|
||||
|
||||
if (savedAgentId && (this.lookups.users || []).some(u => String(u.id) === String(savedAgentId))) {
|
||||
select.value = savedAgentId;
|
||||
} else if (defaultAgent) {
|
||||
select.value = defaultAgent.id;
|
||||
localStorage.setItem('activeAgentId', select.value);
|
||||
} else if ((this.lookups.users || []).length > 0) {
|
||||
select.value = this.lookups.users[0].id;
|
||||
localStorage.setItem('activeAgentId', select.value);
|
||||
@@ -279,7 +324,12 @@ const App = {
|
||||
select.addEventListener('change', () => {
|
||||
localStorage.setItem('activeAgentId', select.value);
|
||||
Toast.success(`Agente attivo cambiato: ${select.options[select.selectedIndex].text}`);
|
||||
this.updateDailyTimer();
|
||||
this.route();
|
||||
});
|
||||
|
||||
// Initial update
|
||||
this.updateDailyTimer();
|
||||
} catch (err) {
|
||||
console.error('Failed to init agent selector:', err);
|
||||
}
|
||||
@@ -296,6 +346,145 @@ const App = {
|
||||
if (lower.includes('very high') || lower.includes('5')) return 5;
|
||||
return 3;
|
||||
},
|
||||
|
||||
/** Initialize and handle theme selection */
|
||||
initTheme() {
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
const savedTheme = localStorage.getItem('app-theme') || 'light';
|
||||
|
||||
// Apply the saved theme class to body
|
||||
this.applyThemeClass(savedTheme);
|
||||
|
||||
if (themeSelect) {
|
||||
themeSelect.value = savedTheme;
|
||||
themeSelect.addEventListener('change', () => {
|
||||
const selectedTheme = themeSelect.value;
|
||||
this.applyThemeClass(selectedTheme);
|
||||
localStorage.setItem('app-theme', selectedTheme);
|
||||
Toast.success(`Tema cambiato in: ${themeSelect.options[themeSelect.selectedIndex].text}`);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** Helper to apply theme classes to document.body */
|
||||
applyThemeClass(theme) {
|
||||
// Remove any existing theme- classes
|
||||
document.body.className = document.body.className
|
||||
.split(' ')
|
||||
.filter(c => !c.startsWith('theme-'))
|
||||
.join(' ');
|
||||
|
||||
if (theme !== 'light') {
|
||||
document.body.classList.add(`theme-${theme}`);
|
||||
}
|
||||
},
|
||||
|
||||
/** Load demotivational phrases from txt file */
|
||||
async loadDemotivationalPhrases() {
|
||||
try {
|
||||
const res = await fetch('/demotivational.txt');
|
||||
if (res.ok) {
|
||||
const text = await res.text();
|
||||
this.demotivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load demotivational phrases:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/** Load motivational phrases from txt file */
|
||||
async loadMotivationalPhrases() {
|
||||
try {
|
||||
const res = await fetch('/motivational.txt');
|
||||
if (res.ok) {
|
||||
const text = await res.text();
|
||||
this.motivationalPhrases = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load motivational phrases:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/** Fetch daily time accounting and update sidebar display */
|
||||
async updateDailyTimer() {
|
||||
const timerEl = document.getElementById('daily-timer');
|
||||
if (!timerEl) return;
|
||||
|
||||
try {
|
||||
await this.ensureLookups();
|
||||
const targetTime = this.lookups.config?.dailyTargetTime || 480;
|
||||
|
||||
const data = await this.api('/api/users/time-today');
|
||||
const todayTime = typeof data.totalToday === 'number' ? data.totalToday : 0;
|
||||
const remaining = Math.max(0, targetTime - todayTime);
|
||||
const percentage = Math.min(100, Math.round((todayTime / targetTime) * 100));
|
||||
|
||||
let phrase = "";
|
||||
const isDemotivational = percentage >= 70;
|
||||
|
||||
if (isDemotivational) {
|
||||
if (this.demotivationalPhrases.length > 0) {
|
||||
const daySeed = new Date().getDate() + todayTime;
|
||||
const idx = Math.floor(Math.abs(Math.sin(daySeed) * this.demotivationalPhrases.length));
|
||||
phrase = this.demotivationalPhrases[idx % this.demotivationalPhrases.length];
|
||||
} else {
|
||||
phrase = "Hai fatto fin troppo lavoro per oggi. Smetti.";
|
||||
}
|
||||
} else {
|
||||
if (this.motivationalPhrases.length > 0) {
|
||||
const daySeed = new Date().getDate() + todayTime;
|
||||
const idx = Math.floor(Math.abs(Math.sin(daySeed) * this.motivationalPhrases.length));
|
||||
phrase = this.motivationalPhrases[idx % this.motivationalPhrases.length];
|
||||
} else {
|
||||
phrase = "Continua così! Stai andando alla grande.";
|
||||
}
|
||||
}
|
||||
|
||||
timerEl.innerHTML = `
|
||||
<div class="timer-display" style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; font-weight:500;">
|
||||
<span>⏱ ${todayTime} / ${targetTime} m</span>
|
||||
<span style="color:var(--text-muted);">|</span>
|
||||
<span>rimanenti: ${remaining} m</span>
|
||||
</div>
|
||||
<div class="timer-tooltip">
|
||||
<div class="timer-tooltip-border"></div>
|
||||
<div style="font-size:0.75rem; font-weight:600; color:var(--text-primary); margin-bottom:4px; display:flex; justify-content:space-between;">
|
||||
<span>Progresso Giornaliero</span>
|
||||
<strong>${percentage}%</strong>
|
||||
</div>
|
||||
<div style="background:var(--border-light); border-radius:var(--radius-full); height:10px; width:100%; overflow:hidden; border:1px solid var(--border-subtle);">
|
||||
<div style="width:${percentage}%; background:linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); height:100%; border-radius:inherit; transition: width 0.3s ease;"></div>
|
||||
</div>
|
||||
<div style="font-size:0.72rem; color:var(--text-secondary); line-height:1.35; font-style:italic; margin-top:6px; border-top:1px solid var(--border-subtle); padding-top:6px; text-align:center;">
|
||||
"${phrase}"
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// Update sidebar counts as well
|
||||
this.updateSidebarBadges();
|
||||
} catch (err) {
|
||||
console.warn('Failed to update daily timer:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/** Fetch dashboard stats to update sidebar counts */
|
||||
async updateSidebarBadges() {
|
||||
try {
|
||||
const stats = await this.api('/api/dashboard/stats');
|
||||
|
||||
const badge = document.getElementById('open-ticket-count');
|
||||
if (badge) {
|
||||
badge.textContent = stats.total_open > 0 ? stats.total_open : '';
|
||||
}
|
||||
|
||||
const myBadge = document.getElementById('my-ticket-count');
|
||||
if (myBadge) {
|
||||
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to update sidebar badges:', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Start the app when DOM is ready
|
||||
|
||||
@@ -106,8 +106,15 @@ const Filters = {
|
||||
|
||||
/** Bind change events to filter selects */
|
||||
bindEvents(onFilterChange) {
|
||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||
const selects = document.querySelectorAll('.filter-select[data-filter]');
|
||||
selects.forEach(sel => {
|
||||
if (isMyTickets && sel.dataset.filter === 'user_id') {
|
||||
sel.disabled = true;
|
||||
} else {
|
||||
sel.disabled = false;
|
||||
}
|
||||
|
||||
sel.addEventListener('change', (e) => {
|
||||
this.state[e.target.dataset.filter] = e.target.value;
|
||||
this.save();
|
||||
@@ -119,7 +126,18 @@ const Filters = {
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => {
|
||||
this.reset();
|
||||
selects.forEach(s => s.value = '');
|
||||
if (isMyTickets) {
|
||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
this.state.user_id = activeAgentId;
|
||||
this.save();
|
||||
}
|
||||
selects.forEach(s => {
|
||||
if (isMyTickets && s.dataset.filter === 'user_id') {
|
||||
s.value = localStorage.getItem('activeAgentId') || '1';
|
||||
} else {
|
||||
s.value = '';
|
||||
}
|
||||
});
|
||||
if (onFilterChange) onFilterChange();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,8 +134,14 @@ const DashboardView = {
|
||||
|
||||
// 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;
|
||||
if (badge) {
|
||||
badge.textContent = stats.total_open > 0 ? stats.total_open : '';
|
||||
}
|
||||
|
||||
// Update my ticket count in sidebar badge
|
||||
const myBadge = document.getElementById('my-ticket-count');
|
||||
if (myBadge) {
|
||||
myBadge.textContent = stats.total_my_open > 0 ? stats.total_my_open : '';
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
@@ -45,7 +45,7 @@ const TicketCreateView = {
|
||||
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,
|
||||
body: this.quill ? this.quill.root.innerHTML : '',
|
||||
isAdvancedVisible: document.getElementById('advanced-options').style.display !== 'none'
|
||||
};
|
||||
},
|
||||
@@ -192,7 +192,9 @@ const TicketCreateView = {
|
||||
|
||||
<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 id="create-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden;">
|
||||
<div id="create-body-editor" style="min-height: 200px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -221,6 +223,30 @@ const TicketCreateView = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize Quill Editor
|
||||
if (window.Quill) {
|
||||
this.quill = new Quill('#create-body-editor', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Descrivi il problema in dettaglio...',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Set saved body/state if available
|
||||
if (this.savedState && this.savedState.body) {
|
||||
this.quill.root.innerHTML = this.savedState.body;
|
||||
}
|
||||
} else {
|
||||
this.quill = null;
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
|
||||
} catch (err) {
|
||||
@@ -607,7 +633,7 @@ const TicketCreateView = {
|
||||
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,
|
||||
body: this.quill ? this.quill.root.innerHTML.trim() : undefined,
|
||||
attachments: this.attachments.length > 0 ? this.attachments : undefined
|
||||
};
|
||||
|
||||
@@ -624,6 +650,9 @@ const TicketCreateView = {
|
||||
this.savedState = null; // Clear cached state on success
|
||||
this.attachments = []; // Clear attachments array
|
||||
|
||||
// Update daily timer
|
||||
App.updateDailyTimer();
|
||||
|
||||
// Navigate to the new ticket
|
||||
window.location.hash = `#/tickets/${result.id}`;
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ const TicketDetailView = {
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
|
||||
// Display total time in the topbar
|
||||
const titleEl = document.getElementById('page-title');
|
||||
if (titleEl) {
|
||||
titleEl.innerHTML = `Ticket #${id} <span style="font-size:0.85rem; font-weight:normal; color:var(--text-secondary); margin-left:var(--space-md); background:var(--bg-tertiary); padding:4px 10px; border-radius:var(--radius-sm); border:1px solid var(--border-light); display:inline-flex; align-items:center; gap:4px;">⏱ Tempo Consultivato: <strong>${totalTime} min</strong></span>`;
|
||||
}
|
||||
|
||||
this.originalValues = {
|
||||
ticket_state_id: ticket.ticket_state_id,
|
||||
ticket_priority_id: ticket.ticket_priority_id,
|
||||
@@ -119,11 +125,13 @@ const TicketDetailView = {
|
||||
<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">
|
||||
<div id="note-body-container" style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-md);">
|
||||
<div id="note-body-editor" style="min-height: 180px; font-family: inherit; font-size: 0.95rem; border: none; color: var(--text-primary);"></div>
|
||||
</div>
|
||||
<div style="display:flex; gap:var(--space-md); justify-content:flex-end; align-items:center; margin-top:var(--space-md);">
|
||||
<input type="number" step="any" min="0" class="note-subject-input" id="note-time-units" placeholder="Tempo (minuti)" style="width:140px; margin-bottom:0; height:32px; padding:4px 10px; font-size:0.85rem;" />
|
||||
<button class="btn btn-primary btn-sm" id="note-send" style="height:32px; display:flex; align-items:center; gap:var(--space-xs);">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
|
||||
Invia Nota
|
||||
</button>
|
||||
@@ -141,14 +149,14 @@ const TicketDetailView = {
|
||||
</div>
|
||||
<div class="articles-timeline">
|
||||
${articles.length > 0 ? articles.map(a => {
|
||||
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
|
||||
const displayBody = this.htmlMode && hasHtml
|
||||
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
||||
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
||||
|
||||
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
|
||||
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('</') || a.a_body.includes('/>');
|
||||
const displayBody = this.htmlMode && hasHtml
|
||||
? `<iframe srcdoc="${a.a_body.replace(/"/g, '"')}" style="width:100%; border:none; background:var(--bg-card); border-radius:var(--radius-md); min-height:220px; font-family:inherit; color-scheme: dark;"></iframe>`
|
||||
: `<div class="article-body">${App.escapeHtml(a.a_body || '')}</div>`;
|
||||
|
||||
return `
|
||||
const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
|
||||
|
||||
return `
|
||||
<div class="article-card sender-${(a.sender_type || 'system').toLowerCase()}">
|
||||
<div class="article-header">
|
||||
<div class="article-sender">
|
||||
@@ -189,7 +197,7 @@ const TicketDetailView = {
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('') : `
|
||||
}).join('') : `
|
||||
<div class="empty-state" style="padding:var(--space-lg);">
|
||||
<div class="empty-state-icon">💬</div>
|
||||
<div class="empty-state-text">Nessun articolo</div>
|
||||
@@ -207,6 +215,10 @@ const TicketDetailView = {
|
||||
<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">Stato</span>
|
||||
<span class="meta-value"><span class="badge badge-state" data-state-type="${(ticket.state_type || '').toLowerCase()}">${ticket.state_name}</span></span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Creato</span>
|
||||
<span class="meta-value">
|
||||
@@ -294,6 +306,24 @@ const TicketDetailView = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize Quill Editor for Note
|
||||
if (window.Quill) {
|
||||
this.noteQuill = new Quill('#note-body-editor', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Scrivi una nota interna...',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.noteQuill = null;
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
|
||||
} catch (err) {
|
||||
@@ -371,12 +401,33 @@ const TicketDetailView = {
|
||||
// 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();
|
||||
let body = this.noteQuill ? this.noteQuill.root.innerHTML.trim() : '';
|
||||
if (body === '<p><br></p>') {
|
||||
body = '';
|
||||
}
|
||||
let 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');
|
||||
// If body is empty but subject is provided, fill body with subject text
|
||||
if (!body && subject) {
|
||||
body = `<p>${App.escapeHtml(subject)}</p>`;
|
||||
if (this.noteQuill) {
|
||||
this.noteQuill.root.innerHTML = body;
|
||||
}
|
||||
}
|
||||
|
||||
// If subject is empty but body is provided, fill subject with truncated plain text of body
|
||||
if (body && !subject) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = body;
|
||||
const plainText = tempDiv.textContent || tempDiv.innerText || '';
|
||||
subject = plainText.trim().substring(0, 50) || 'Nota interna';
|
||||
document.getElementById('note-subject').value = subject;
|
||||
}
|
||||
|
||||
// If both are still empty, show warning
|
||||
if (!body && !subject) {
|
||||
Toast.warning('Inserisci un oggetto o il corpo della nota prima di inviare');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,6 +441,7 @@ const TicketDetailView = {
|
||||
});
|
||||
|
||||
Toast.success(res.message || 'Nota aggiunta!');
|
||||
App.updateDailyTimer();
|
||||
this.render(this.ticketId);
|
||||
} catch (err) {
|
||||
Toast.error('Errore: ' + err.message);
|
||||
|
||||
@@ -8,6 +8,7 @@ const TicketListView = {
|
||||
sortBy: 'create_time',
|
||||
sortDir: 'DESC',
|
||||
selectedIds: new Set(),
|
||||
selectedOrder: [],
|
||||
searchTimeout: null,
|
||||
|
||||
async render() {
|
||||
@@ -19,17 +20,23 @@ const TicketListView = {
|
||||
await App.ensureLookups();
|
||||
|
||||
// Build query params
|
||||
const isMyTickets = window.location.hash.startsWith('#/tickets/my');
|
||||
if (isMyTickets) {
|
||||
const activeAgentId = localStorage.getItem('activeAgentId') || '1';
|
||||
Filters.state.user_id = activeAgentId;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -79,6 +86,7 @@ const TicketListView = {
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="batch-apply">Applica</button>
|
||||
<button class="btn btn-primary btn-sm" id="batch-merge" disabled style="background: var(--accent-secondary); border-color: var(--accent-secondary); margin-left: 8px;">Unisci Selezionati</button>
|
||||
<button class="btn btn-ghost btn-xs" id="batch-cancel">Annulla</button>
|
||||
</div>
|
||||
|
||||
@@ -95,18 +103,20 @@ const TicketListView = {
|
||||
<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>Cliente</th>
|
||||
<th class="sortable ${this.sortBy === 'create_time' ? (this.sortDir === 'ASC' ? 'sort-asc' : 'sort-desc') : ''}" data-sort="create_time">Creato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tickets.length > 0 ? tickets.map(t => `
|
||||
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''}" style="cursor:pointer;">
|
||||
<tr data-ticket-id="${t.id}" class="${this.selectedIds.has(String(t.id)) ? 'selected' : ''} ${this.selectedOrder[0] === String(t.id) ? 'first-selected' : ''}" style="cursor:pointer;">
|
||||
<td><span class="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)}">${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-secondary);font-size:0.82rem;">${t.customer_first ? `${t.customer_first} ${t.customer_last}` : (t.customer_user_id || '—')}</td>
|
||||
<td style="color:var(--text-tertiary);font-size:0.78rem;">${App.formatDateTime(t.create_time)}</td>
|
||||
</tr>
|
||||
`).join('') : `
|
||||
@@ -163,6 +173,7 @@ const TicketListView = {
|
||||
Filters.bindEvents(() => {
|
||||
this.currentPage = 1;
|
||||
this.selectedIds.clear();
|
||||
this.selectedOrder = [];
|
||||
this.render();
|
||||
});
|
||||
|
||||
@@ -192,11 +203,27 @@ const TicketListView = {
|
||||
const id = String(row.dataset.ticketId);
|
||||
if (this.selectedIds.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
row.classList.remove('selected');
|
||||
this.selectedOrder = this.selectedOrder.filter(x => x !== id);
|
||||
} else {
|
||||
this.selectedIds.add(id);
|
||||
row.classList.add('selected');
|
||||
this.selectedOrder.push(id);
|
||||
}
|
||||
|
||||
// Update classes on all rows
|
||||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(tr => {
|
||||
const trId = String(tr.dataset.ticketId);
|
||||
if (this.selectedIds.has(trId)) {
|
||||
tr.classList.add('selected');
|
||||
} else {
|
||||
tr.classList.remove('selected');
|
||||
}
|
||||
if (this.selectedOrder[0] === trId) {
|
||||
tr.classList.add('first-selected');
|
||||
} else {
|
||||
tr.classList.remove('first-selected');
|
||||
}
|
||||
});
|
||||
|
||||
this.updateBatchBar();
|
||||
});
|
||||
});
|
||||
@@ -207,15 +234,35 @@ const TicketListView = {
|
||||
batchApply.addEventListener('click', () => this.applyBatch());
|
||||
}
|
||||
|
||||
// Batch merge (Issue #7)
|
||||
const batchMerge = document.getElementById('batch-merge');
|
||||
if (batchMerge) {
|
||||
batchMerge.addEventListener('click', () => this.mergeBatch());
|
||||
}
|
||||
|
||||
// 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');
|
||||
if (!this.selectedIds.has(id)) {
|
||||
this.selectedIds.add(id);
|
||||
this.selectedOrder.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update classes
|
||||
document.querySelectorAll('.ticket-table tbody tr[data-ticket-id]').forEach(tr => {
|
||||
const trId = String(tr.dataset.ticketId);
|
||||
tr.classList.add('selected');
|
||||
if (this.selectedOrder[0] === trId) {
|
||||
tr.classList.add('first-selected');
|
||||
} else {
|
||||
tr.classList.remove('first-selected');
|
||||
}
|
||||
});
|
||||
|
||||
this.updateBatchBar();
|
||||
});
|
||||
}
|
||||
@@ -225,8 +272,10 @@ const TicketListView = {
|
||||
if (batchCancel) {
|
||||
batchCancel.addEventListener('click', () => {
|
||||
this.selectedIds.clear();
|
||||
this.selectedOrder = [];
|
||||
document.querySelectorAll('.ticket-table tbody tr').forEach(tr => {
|
||||
tr.classList.remove('selected');
|
||||
tr.classList.remove('first-selected');
|
||||
});
|
||||
this.updateBatchBar();
|
||||
});
|
||||
@@ -237,6 +286,7 @@ const TicketListView = {
|
||||
btn.addEventListener('click', () => {
|
||||
this.currentPage = parseInt(btn.dataset.page);
|
||||
this.selectedIds.clear();
|
||||
this.selectedOrder = [];
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
@@ -247,6 +297,16 @@ const TicketListView = {
|
||||
if (count) {
|
||||
count.textContent = `${this.selectedIds.size} selezionat${this.selectedIds.size === 1 ? 'o' : 'i'}`;
|
||||
}
|
||||
|
||||
// Enable/disable merge button
|
||||
const mergeBtn = document.getElementById('batch-merge');
|
||||
if (mergeBtn) {
|
||||
if (this.selectedIds.size >= 2) {
|
||||
mergeBtn.disabled = false;
|
||||
} else {
|
||||
mergeBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async applyBatch() {
|
||||
@@ -276,9 +336,59 @@ const TicketListView = {
|
||||
});
|
||||
Toast.success(res.message || `${this.selectedIds.size} ticket aggiornati`);
|
||||
this.selectedIds.clear();
|
||||
this.selectedOrder = [];
|
||||
this.render();
|
||||
} catch (err) {
|
||||
Toast.error('Errore aggiornamento batch: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
async mergeBatch() {
|
||||
if (this.selectedOrder.length < 2) return;
|
||||
|
||||
const targetId = this.selectedOrder[0];
|
||||
const sourceIds = this.selectedOrder.slice(1);
|
||||
|
||||
// Get target ticket number
|
||||
const targetRow = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${targetId}"]`);
|
||||
const targetTn = targetRow ? targetRow.querySelector('.ticket-tn-link').textContent.trim() : targetId;
|
||||
|
||||
// Collect source ticket numbers
|
||||
const sourceTns = [];
|
||||
sourceIds.forEach(id => {
|
||||
const row = document.querySelector(`.ticket-table tbody tr[data-ticket-id="${id}"]`);
|
||||
if (row) {
|
||||
sourceTns.push(row.querySelector('.ticket-tn-link').textContent.trim());
|
||||
} else {
|
||||
sourceTns.push(`#${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
const confirmed = confirm(`Sei sicuro di voler unire i ticket ${sourceTns.join(', ')} nel ticket principale #${targetTn}? Questa azione sposterà tutti gli articoli e tempi consultivati.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const mergeBtn = document.getElementById('batch-merge');
|
||||
if (mergeBtn) {
|
||||
mergeBtn.disabled = true;
|
||||
mergeBtn.textContent = 'Unione in corso...';
|
||||
}
|
||||
|
||||
const res = await App.api('/api/tickets/merge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetId: parseInt(targetId),
|
||||
sourceIds: sourceIds.map(x => parseInt(x))
|
||||
})
|
||||
});
|
||||
|
||||
Toast.success(res.message || 'Ticket uniti con successo');
|
||||
this.selectedIds.clear();
|
||||
this.selectedOrder = [];
|
||||
this.render();
|
||||
} catch (err) {
|
||||
Toast.error('Errore durante l\'unione: ' + err.message);
|
||||
this.updateBatchBar();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user