feat: aggiunta consuntivazione automatica tramite variabili env. style: aggiunto bordino anche attorno al tempo nel caso di overperformance

This commit is contained in:
2026-07-08 08:26:10 +02:00
parent fd78b7e283
commit 3b06609fcb
5 changed files with 439 additions and 3 deletions
+10 -1
View File
@@ -34,4 +34,13 @@ FORCE_DB_UPDATE=false
CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc CRYPTO_KEY=f30b91e92d77a06c59b20b2272e2cfbc
# Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%) # Soglia in percentuale del target tempo giornaliero per l'attivazione delle frasi demotivazionali (es. 70 per il 70%)
PHRASE_THRESHOLD=70 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
+71
View File
@@ -2421,4 +2421,75 @@ body {
margin: var(--space-sm); margin: var(--space-sm);
padding: 12px !important; padding: 12px !important;
transition: all 0.5s ease; 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;
} }
+69 -1
View File
@@ -186,10 +186,13 @@ const App = {
if (cached) { if (cached) {
try { try {
this.lookups = JSON.parse(cached); 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; this.lookupsLoaded = true;
return; return;
} catch (e) { } 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 = ""; let phrase = "";
const phraseThreshold = this.lookups.config?.phraseThreshold || 70; const phraseThreshold = this.lookups.config?.phraseThreshold || 70;
const isDemotivational = percentage >= phraseThreshold; const isDemotivational = percentage >= phraseThreshold;
+2 -1
View File
@@ -136,7 +136,8 @@ router.get('/config', (req, res) => {
res.json({ res.json({
defaultAgentLogin: process.env.OTRS_API_USER || '', defaultAgentLogin: process.env.OTRS_API_USER || '',
dailyTargetTime: parseInt(process.env.DAILY_TARGET_TIME, 10) || 480, 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'
}); });
}); });
+287
View File
@@ -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 = `<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body style="font-family:Geneva,Helvetica,Arial,sans-serif; font-size: 12px;">${body} (Mancanti: ${remaining} m)</body></html>`;
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; module.exports = router;