diff --git a/.env.example b/.env.example index c821e3b..3313adc 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,13 @@ FORCE_DB_UPDATE=false CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc # Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%) -PHRASE_THRESHOLD=70 \ No newline at end of file +PHRASE_THRESHOLD=70 + +# Configurazioni per la consuntivazione automatica fine giornata +AUTO_TIME_MIN_HOUR=18:00 +AUTO_TIME_QUEUE=Assistenza +AUTO_TIME_TYPE=Default +AUTO_TIME_TITLE=Consuntivazione Automatica fine giornata +AUTO_TIME_SUBJECT=Consuntivazione automatica ore mancanti +AUTO_TIME_BODY=Consuntivazione eseguita automaticamente per il completamento delle ore lavorative giornaliere. +AUTO_TIME_CUSTOMER_USER=client_generic \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css index d30ceb3..f7b5b4a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2421,4 +2421,75 @@ body { margin: var(--space-sm); padding: 12px !important; transition: all 0.5s ease; +} + +@keyframes autotime-pulse { + 0% { + box-shadow: 0 0 5px rgba(16, 112, 202, 0.4); + border-color: rgba(16, 112, 202, 0.5); + } + 50% { + box-shadow: 0 0 15px rgba(16, 112, 202, 0.8), inset 0 0 5px rgba(16, 112, 202, 0.3); + border-color: rgba(16, 112, 202, 0.9); + background: rgba(16, 112, 202, 0.15); + } + 100% { + box-shadow: 0 0 5px rgba(16, 112, 202, 0.4); + border-color: rgba(16, 112, 202, 0.5); + } +} + +.sidebar-brand.clickable-auto-time { + cursor: pointer; + animation: autotime-pulse 2.5s infinite ease-in-out; + border: 1px dashed var(--accent-primary) !important; + border-radius: var(--radius-md); + margin: var(--space-xs) var(--space-sm); + padding: 8px 12px !important; + transition: all 0.3s ease; +} + +.sidebar-brand.clickable-auto-time:hover { + filter: brightness(1.2); + transform: translateY(-1px); +} + +.sidebar-timer.clickable-auto-time { + cursor: pointer; + animation: autotime-pulse 2.5s infinite ease-in-out; + border: 1px dashed var(--accent-primary) !important; + border-radius: var(--radius-md); + margin: var(--space-xs) var(--space-sm); + padding: 8px 12px !important; + transition: all 0.3s ease; +} + +.sidebar-timer.clickable-auto-time:hover { + filter: brightness(1.2); + transform: translateY(-1px); +} + +@keyframes overperformance-alarm-pulse { + 0% { + box-shadow: 0 0 5px rgba(239, 68, 68, 0.4); + border-color: rgba(239, 68, 68, 0.6); + } + 50% { + box-shadow: 0 0 15px rgba(239, 68, 68, 0.8), inset 0 0 5px rgba(239, 68, 68, 0.3); + border-color: rgba(239, 68, 68, 0.9); + background: rgba(239, 68, 68, 0.15) !important; + } + 100% { + box-shadow: 0 0 5px rgba(239, 68, 68, 0.4); + border-color: rgba(239, 68, 68, 0.6); + } +} + +.sidebar-timer.overperformance-alarm { + animation: overperformance-alarm-pulse 1.5s infinite ease-in-out; + border: 1px solid rgba(239, 68, 68, 0.6) !important; + border-radius: var(--radius-md); + margin: var(--space-xs) var(--space-sm); + padding: 8px 12px !important; + transition: all 0.3s ease; } \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 9f3c037..6090db7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -186,10 +186,13 @@ const App = { if (cached) { try { this.lookups = JSON.parse(cached); + if (!this.lookups.config || this.lookups.config.autoTimeMinHour === undefined) { + throw new Error('Outdated config cache (missing autoTimeMinHour)'); + } this.lookupsLoaded = true; return; } catch (e) { - console.warn('Failed to parse cached lookups, reloading...', e); + console.warn('Failed to parse cached lookups, reloading...', e.message); } } } @@ -436,6 +439,71 @@ const App = { } } + if (timerEl) { + if (hasOverperformance) { + timerEl.classList.add('overperformance-alarm'); + } else { + timerEl.classList.remove('overperformance-alarm'); + } + } + + const minTimeStr = this.lookups.config?.autoTimeMinHour || '18:00'; + const [minHour, minMin] = minTimeStr.split(':').map(x => parseInt(x, 10)); + + const now = new Date(); + const currentHour = now.getHours(); + const currentMin = now.getMinutes(); + + let isPastTime = false; + if (currentHour > minHour) { + isPastTime = true; + } else if (currentHour === minHour && currentMin >= minMin) { + isPastTime = true; + } + + const triggerAction = async (e) => { + e.preventDefault(); + e.stopPropagation(); + const confirmed = confirm(`Sei sicuro di voler effettuare la consuntivazione automatica di ${remaining} minuti rimanenti di oggi? Verrà creato un ticket chiuso a tuo carico.`); + if (!confirmed) return; + + try { + Toast.success('Consuntivazione in corso...'); + const res = await this.api('/api/tickets/auto-time', { method: 'POST' }); + Toast.success(res.message || 'Consuntivazione completata!'); + this.updateDailyTimer(); + this.route(); + } catch (err) { + Toast.error('Errore consuntivazione automatica: ' + err.message); + } + }; + + if (isPastTime && remaining > 0) { + if (brandEl) { + brandEl.classList.add('clickable-auto-time'); + brandEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m)`); + brandEl.onclick = triggerAction; + } + + if (timerEl) { + timerEl.classList.add('clickable-auto-time'); + timerEl.setAttribute('title', `Consuntivazione Automatica fine giornata (${remaining} m). Clicca per eseguire.`); + timerEl.onclick = triggerAction; + } + } else { + if (brandEl) { + brandEl.classList.remove('clickable-auto-time'); + brandEl.removeAttribute('title'); + brandEl.onclick = null; + } + + if (timerEl) { + timerEl.classList.remove('clickable-auto-time'); + timerEl.removeAttribute('title'); + timerEl.onclick = null; + } + } + let phrase = ""; const phraseThreshold = this.lookups.config?.phraseThreshold || 70; const isDemotivational = percentage >= phraseThreshold; diff --git a/routes/lookups.js b/routes/lookups.js index 06b480e..ffdae8c 100644 --- a/routes/lookups.js +++ b/routes/lookups.js @@ -136,7 +136,8 @@ router.get('/config', (req, res) => { res.json({ defaultAgentLogin: process.env.OTRS_API_USER || '', dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480, - phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70 + phraseThreshold: parseInt(process.env.PHRASE_THRESHOLD, 10) || 70, + autoTimeMinHour: process.env.AUTO_TIME_MIN_HOUR || '18:00' }); }); diff --git a/routes/tickets.js b/routes/tickets.js index 3afa5b0..65e806d 100644 --- a/routes/tickets.js +++ b/routes/tickets.js @@ -1685,4 +1685,291 @@ router.put('/articles/:articleId/time', async (req, res) => { } }); +// ============================================================ +// POST /api/tickets/auto-time — Automatic end-of-day time accounting ticket +// ============================================================ +router.post('/auto-time', async (req, res) => { + const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1; + + // Verify time + const minTimeStr = process.env.AUTO_TIME_MIN_HOUR || '18:00'; + const [minHour, minMin] = minTimeStr.split(':').map(x => parseInt(x, 10)); + + const now = new Date(); + const currentHour = now.getHours(); + const currentMin = now.getMinutes(); + + let isPastTime = false; + if (currentHour > minHour) { + isPastTime = true; + } else if (currentHour === minHour && currentMin >= minMin) { + isPastTime = true; + } + + if (!isPastTime) { + return res.status(400).json({ error: `La consuntivazione automatica è consentita solo dopo le ore ${minTimeStr}` }); + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // 1. Calculate remaining time + const targetTime = parseInt(process.env.DAILY_TARGET_TIME, 10) || 480; + const timeTodayRes = await client.query( + `SELECT COALESCE(SUM(time_unit), 0) AS total_today + FROM time_accounting + WHERE create_by = $1 AND DATE(create_time) = CURRENT_DATE`, + [operatorId] + ); + const totalToday = parseFloat(timeTodayRes.rows[0].total_today); + const remaining = Math.max(0, targetTime - totalToday); + + if (remaining <= 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'La soglia giornaliera è già stata raggiunta o superata' }); + } + + // 2. Resolve parameters from configuration / OTRS DB + const queueName = process.env.AUTO_TIME_QUEUE || 'Assistenza'; + const typeName = process.env.AUTO_TIME_TYPE || 'Default'; + const title = process.env.AUTO_TIME_TITLE || 'Consuntivazione Automatica fine giornata'; + const subject = process.env.AUTO_TIME_SUBJECT || 'Consuntivazione automatica ore mancanti'; + const body = process.env.AUTO_TIME_BODY || 'Consuntivazione automatica fine giornata.'; + const configCustomerUser = process.env.AUTO_TIME_CUSTOMER_USER; + + // Resolve agent login for owner / customer fallback + const agentRes = await client.query( + `SELECT login, first_name, last_name FROM users WHERE id = $1`, + [operatorId] + ); + if (agentRes.rows.length === 0) { + throw new Error(`Impossibile trovare l'agente attivo con ID #${operatorId}`); + } + const agentLogin = agentRes.rows[0].login; + const customerUserId = configCustomerUser || agentLogin; + + // Resolve CustomerID + let customerId = 'Self-Service'; + const custRes = await client.query( + `SELECT customer_id FROM customer_user WHERE login = $1`, + [customerUserId] + ); + if (custRes.rows.length > 0) { + customerId = custRes.rows[0].customer_id; + } + + // Resolve state ID (closed successful / closed fallback) + const stateRes = await client.query( + `SELECT id FROM ticket_state WHERE name = 'closed successful' OR name = 'chiuso con successo' LIMIT 1` + ); + let stateId = stateRes.rows[0]?.id; + if (!stateId) { + const stateFallback = await client.query( + `SELECT ts.id FROM ticket_state ts JOIN ticket_state_type tst ON ts.type_id = tst.id WHERE tst.name = 'closed' LIMIT 1` + ); + stateId = stateFallback.rows[0]?.id || 3; + } + + // Resolve queue ID + const queueRes = await client.query( + `SELECT id FROM queue WHERE name = $1 LIMIT 1`, + [queueName] + ); + const queueId = queueRes.rows[0]?.id || 1; + + // Resolve type ID + const typeRes = await client.query( + `SELECT id FROM ticket_type WHERE name = $1 LIMIT 1`, + [typeName] + ); + const typeId = typeRes.rows[0]?.id || null; + + // Resolve priority ID (3 normal) + const priorityRes = await client.query( + `SELECT id FROM ticket_priority WHERE name = '3 normal' LIMIT 1` + ); + const priorityId = priorityRes.rows[0]?.id || 3; + + // Generate ticket number (daily reset) + const now = new Date(); + const systemId = process.env.OTRS_SYSTEM_ID || '10'; + const datePrefix = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}${systemId}`; + + const maxTicketResult = await client.query( + `SELECT tn FROM ticket WHERE tn LIKE $1`, + [`${datePrefix}%`] + ); + let maxCounterFromTicketTable = 0; + for (const row of maxTicketResult.rows) { + const counterPart = row.tn.substring(datePrefix.length); + const parsed = parseInt(counterPart, 10); + if (!isNaN(parsed) && parsed > maxCounterFromTicketTable) { + maxCounterFromTicketTable = parsed; + } + } + + const counterTodayResult = await client.query( + `SELECT COALESCE(MAX(counter), 0) AS max_counter + FROM ticket_number_counter + WHERE create_time >= CURRENT_DATE` + ); + const maxCounterFromCounterTable = parseInt(counterTodayResult.rows[0].max_counter, 10); + const nextCounter = Math.max(maxCounterFromTicketTable, maxCounterFromCounterTable) + 1; + + const counterResult = await client.query( + `INSERT INTO ticket_number_counter (counter, counter_uid, create_time) + VALUES ($1, md5(random()::text || clock_timestamp()::text), NOW()) + RETURNING counter`, + [nextCounter] + ); + const counter = counterResult.rows[0].counter; + const counterPadding = parseInt(process.env.OTRS_COUNTER_PADDING, 10) || 6; + const tn = `${datePrefix}${String(counter).padStart(counterPadding, '0')}`; + + // 3. Create Ticket (lock = 1 [unlock], owner & responsible = operatorId) + const ticketResult = await client.query( + `INSERT INTO ticket ( + tn, title, queue_id, ticket_lock_id, type_id, + user_id, responsible_user_id, + ticket_priority_id, ticket_state_id, + customer_id, customer_user_id, + timeout, until_time, + escalation_time, escalation_update_time, + escalation_response_time, escalation_solution_time, + archive_flag, + create_time, create_by, change_time, change_by + ) VALUES ( + $1, $2, $3, 1, $4, + $5, $5, + $6, $7, + $8, $9, + 0, 0, + 0, 0, + 0, 0, + 0, + NOW(), $10, NOW(), $10 + ) RETURNING id, tn`, + [ + tn, title, queueId, typeId, + operatorId, + priorityId, stateId, + customerId, customerUserId, + operatorId + ] + ); + const ticketId = ticketResult.rows[0].id; + + // Ticket History for NewTicket + const htResult = await client.query( + `SELECT id FROM ticket_history_type WHERE name = 'NewTicket'` + ); + const historyTypeId = htResult.rows.length > 0 ? htResult.rows[0].id : 1; + await client.query( + `INSERT INTO ticket_history ( + name, history_type_id, ticket_id, type_id, queue_id, + owner_id, priority_id, state_id, + create_time, create_by, change_time, change_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9, NOW(), $9)`, + ['%%', historyTypeId, ticketId, typeId || 1, queueId, operatorId, priorityId, stateId, operatorId] + ); + + // 4. Create Article (Internal note) + const senderResult = await client.query( + `SELECT id FROM article_sender_type WHERE name = 'agent'` + ); + const senderTypeId = senderResult.rows.length > 0 ? senderResult.rows[0].id : 1; + + const channelResult = await client.query( + `SELECT id FROM communication_channel WHERE name = 'Internal'` + ); + const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1; + + const articleResult = await client.query( + `INSERT INTO article ( + ticket_id, article_sender_type_id, communication_channel_id, + is_visible_for_customer, search_index_needs_rebuild, + create_time, create_by, change_time, change_by + ) VALUES ($1, $2, $3, 0, 1, NOW(), $4, NOW(), $4) RETURNING id`, + [ticketId, senderTypeId, channelId, operatorId] + ); + const articleId = articleResult.rows[0].id; + + // Create article mime data + const nowMime = new Date(); + const contentPath = `${nowMime.getFullYear()}/${String(nowMime.getMonth() + 1).padStart(2, '0')}/${String(nowMime.getDate()).padStart(2, '0')}`; + const htmlBody = `${body} (Mancanti: ${remaining} m)`; + const binaryBody = Buffer.from(htmlBody, 'utf-8'); + const contentSize = Buffer.byteLength(htmlBody, 'utf-8'); + const base64Body = binaryBody.toString('base64'); + + await client.query( + `INSERT INTO article_data_mime ( + article_id, a_from, a_to, a_subject, a_body, + a_content_type, incoming_time, content_path, + create_time, create_by, change_time, change_by + ) VALUES ( + $1, 'OTRS Turbo Agent', '', $2, $3, + 'text/html; charset=utf-8', EXTRACT(EPOCH FROM NOW())::INTEGER, $4, + NOW(), $5, NOW(), $5 + )`, + [articleId, subject, htmlBody, contentPath, operatorId] + ); + + // Create article attachment (file-1) for OTRS CE HTML display + await client.query( + `INSERT INTO article_data_mime_attachment ( + article_id, filename, content_size, content_type, disposition, content, + create_time, create_by, change_time, change_by + ) VALUES ($1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3, NOW(), $4, NOW(), $4)`, + [articleId, String(contentSize), base64Body, operatorId] + ); + + // 5. Create Time Accounting Entry for remaining minutes + await client.query( + `INSERT INTO time_accounting ( + ticket_id, article_id, time_unit, + create_time, create_by, change_time, change_by + ) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`, + [ticketId, articleId, remaining, operatorId] + ); + + await client.query('COMMIT'); + + // Log activity + resolveAgentName(operatorId).then(agente_nome => { + logAttivita({ + agente_id: operatorId, + agente_nome, + titolo_azione: 'Consuntivazione Automatica fine giornata', + azione: { ticket_id: ticketId, tn, minutes: remaining }, + esito: 'successo', + }); + }); + + res.status(201).json({ + id: ticketId, + tn, + minutes: remaining, + message: 'Consuntivazione automatica completata con successo!' + }); + + } catch (err) { + await client.query('ROLLBACK'); + console.error('Error in auto-time sheet creation:', err); + resolveAgentName(operatorId).then(agente_nome => { + logAttivita({ + agente_id: operatorId, + agente_nome, + titolo_azione: 'Consuntivazione Automatica fine giornata', + azione: { error: err.message }, + esito: 'errore' + }); + }); + res.status(500).json({ error: err.message }); + } finally { + client.release(); + } +}); + module.exports = router;