feat: potenziata visualizzazione storico consuntivazione. style: disabilitata apertura massiva. feat: migliorata modifica pdf e scaricamento di tutti gli allegati tramite shortcut.
This commit is contained in:
1 parent
b1754c3a67
commit
c7b18ac959
10 files changed
+1545
-191
No files matched your search
@@ -84,10 +84,15 @@ db.exec(`
|
||||
agent_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL,
|
||||
image_data TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
)
|
||||
`);
|
||||
|
||||
try {
|
||||
db.exec(`ALTER TABLE user_signatures ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (_) {}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_shapes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
+41
-4
@@ -2708,10 +2708,47 @@ body {
|
||||
.article-info-popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
bottom: -25px;
|
||||
left: -30px;
|
||||
right: -80px;
|
||||
top: -8px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* PDF.js Text Layer Styling per selezione testo pulita */
|
||||
.textLayer {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1.0;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
}
|
||||
|
||||
.textLayer span,
|
||||
.textLayer br {
|
||||
color: transparent !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.textLayer ::selection,
|
||||
.textLayer ::-moz-selection,
|
||||
.textLayer span::selection,
|
||||
.textLayer span::-moz-selection {
|
||||
background: rgba(59, 130, 246, 0.35) !important;
|
||||
color: transparent !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
}
|
||||
+11
-9
@@ -112,6 +112,7 @@
|
||||
<span>Nuovo Ticket</span>
|
||||
</a>
|
||||
</li>
|
||||
<!--
|
||||
<li>
|
||||
<a href="#/tickets/bulk" class="nav-link" data-view="bulk-tickets" id="nav-bulk-tickets">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -123,6 +124,7 @@
|
||||
<span>Apertura Massiva</span>
|
||||
</a>
|
||||
</li>
|
||||
-->
|
||||
<hr>
|
||||
<li>
|
||||
<a href="#/mail-management" class="nav-link" data-view="mail-management" id="nav-mail-management">
|
||||
@@ -142,6 +144,15 @@
|
||||
<span>Configurazioni Utente</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#/shortcuts" class="nav-link" data-view="shortcuts" id="nav-shortcuts">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
||||
</svg>
|
||||
<span>Shortcuts</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#/config-env" class="nav-link" data-view="config-env" id="nav-config-env">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -171,15 +182,6 @@
|
||||
<span>Storico Attività Turbo</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#/shortcuts" class="nav-link" data-view="shortcuts" id="nav-shortcuts">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
|
||||
</svg>
|
||||
<span>Shortcuts</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
|
||||
@@ -64,6 +64,7 @@ const App = {
|
||||
create_ticket_open: { ctrl: true, alt: true, shift: false, key: 'n', enabled: true },
|
||||
create_ticket_submit: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||
quick_edit_save: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||
pdf_save_edited: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true },
|
||||
};
|
||||
return defaults[id] || { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true };
|
||||
},
|
||||
|
||||
@@ -78,6 +78,13 @@ const ShortcutsView = {
|
||||
description: 'Salva le modifiche apportate nei campi di Modifica Rapida',
|
||||
context: 'Pagina Dettaglio Ticket (Modifica Rapida)',
|
||||
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||
},
|
||||
{
|
||||
id: 'pdf_save_edited',
|
||||
name: 'Salva Allegato / PDF Modificato',
|
||||
description: 'Scarica l\'allegato corrente o salva il PDF con le annotazioni apportate',
|
||||
context: 'Anteprima Allegato / PDF',
|
||||
defaultCombo: { ctrl: true, alt: false, shift: false, key: 'Enter', enabled: true }
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+575
-63
@@ -246,6 +246,7 @@ const TicketDetailView = {
|
||||
<button id="pdf-tool-text" class="btn btn-ghost btn-sm" title="Aggiungi Testo" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🔤 Testo</button>
|
||||
<button id="pdf-tool-highlight" class="btn btn-ghost btn-sm" title="Evidenziatore" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">🖍️ Evidenzia</button>
|
||||
<button id="pdf-tool-shape" class="btn btn-ghost btn-sm" title="Inserisci Forme" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">📐 Forme</button>
|
||||
<button id="pdf-tool-signature" class="btn btn-ghost btn-sm" title="Apponi Timbro / Firma Utente" style="color:#ffffff; height:28px; padding:0 8px; font-size:0.8rem; background:transparent; border-radius:4px; border:none; cursor:pointer;">✍️ Firma</button>
|
||||
|
||||
<!-- Main Color Picker -->
|
||||
<input type="color" id="pdf-color-picker" value="#ef4444" title="Colore primario" style="width:26px; height:26px; padding:0; border:none; border-radius:50%; cursor:pointer; background:none;" />
|
||||
@@ -255,6 +256,8 @@ const TicketDetailView = {
|
||||
<select id="pdf-shape-type" style="height: 26px; background: #0f172a; color: #fff; border: 1px solid rgba(255,255,255,0.3); border-radius: 4px; font-size: 0.76rem; padding: 0 4px; cursor: pointer;">
|
||||
<option value="rectangle">🟦 Rettangolo</option>
|
||||
<option value="circle">⚪ Cerchio</option>
|
||||
<option value="line">📏 Linea</option>
|
||||
<option value="arrow">🏹 Freccia</option>
|
||||
</select>
|
||||
<label style="font-size:0.72rem; color:#cbd5e1; display:flex; align-items:center; gap:2px;" title="Colore Interno">
|
||||
Riemp.
|
||||
@@ -339,19 +342,45 @@ const TicketDetailView = {
|
||||
overlay.focus();
|
||||
};
|
||||
|
||||
this.pdfHasBeenSaved = false;
|
||||
|
||||
const checkPdfHasModifications = () => {
|
||||
if (this.pdfHasBeenSaved) return false;
|
||||
const canvases = document.querySelectorAll('.pdf-annotation-canvas');
|
||||
for (const c of canvases) {
|
||||
if (c.pageObjects && c.pageObjects.length > 0) return true;
|
||||
const ctx = c.getContext('2d');
|
||||
const imgData = ctx.getImageData(0, 0, c.width, c.height);
|
||||
if (imgData.data.some(channel => channel !== 0)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const confirmDiscardChanges = () => {
|
||||
if (checkPdfHasModifications()) {
|
||||
return confirm('Attenzione: Sono presenti modifiche non salvate al PDF. Vuoi davvero uscire o cambiare allegato senza salvare?');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const showPrev = () => {
|
||||
if (itemsList.length <= 1) return;
|
||||
if (!confirmDiscardChanges()) return;
|
||||
this.pdfHasBeenSaved = false;
|
||||
currentIndex = (currentIndex - 1 + itemsList.length) % itemsList.length;
|
||||
updateDisplay();
|
||||
};
|
||||
|
||||
const showNext = () => {
|
||||
if (itemsList.length <= 1) return;
|
||||
if (!confirmDiscardChanges()) return;
|
||||
this.pdfHasBeenSaved = false;
|
||||
currentIndex = (currentIndex + 1) % itemsList.length;
|
||||
updateDisplay();
|
||||
};
|
||||
|
||||
const closeLightbox = () => {
|
||||
if (!confirmDiscardChanges()) return;
|
||||
window.currentPdfDoc = null;
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
window.removeEventListener('keydown', handleKeydown, true);
|
||||
@@ -449,6 +478,7 @@ const TicketDetailView = {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
this.pdfHasBeenSaved = true;
|
||||
Toast.success(`PDF modificato scaricato: ${outName}`);
|
||||
} catch (saveErr) {
|
||||
console.error('Error saving edited PDF', saveErr);
|
||||
@@ -462,12 +492,32 @@ const TicketDetailView = {
|
||||
};
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
// If user is editing text in an input, textarea or contenteditable element, allow standard cursor navigation
|
||||
const activeTag = document.activeElement ? document.activeElement.tagName.toUpperCase() : '';
|
||||
const isInputOrTextarea = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || (document.activeElement && document.activeElement.isContentEditable);
|
||||
if (isInputOrTextarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCtrlOrCmd && e.key === 'Enter') {
|
||||
const currentItem = itemsList[currentIndex];
|
||||
if (currentItem && App.isShortcutTriggered(e, 'pdf_save_edited')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
savePdfDocument();
|
||||
if (currentItem.type === 'pdf') {
|
||||
savePdfDocument();
|
||||
} else {
|
||||
// Non-PDF attachment (e.g. image): trigger download/open link
|
||||
const downloadBtn = overlay.querySelector('#lightbox-download-btn');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.click();
|
||||
} else if (currentItem.src) {
|
||||
const a = document.createElement('a');
|
||||
a.href = currentItem.src;
|
||||
a.download = currentItem.filename || 'allegato';
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -542,13 +592,25 @@ const TicketDetailView = {
|
||||
|
||||
if (pageNumEl) pageNumEl.textContent = `Pagina 1 / ${totalPages}`;
|
||||
|
||||
let currentTool = 'draw'; // 'draw' | 'text' | 'highlight'
|
||||
let currentTool = 'draw'; // 'draw' | 'text' | 'highlight' | 'shape' | 'signature'
|
||||
let currentColor = '#ef4444';
|
||||
let activeSignature = null;
|
||||
|
||||
// Pre-fetch signatures to select default signature
|
||||
try {
|
||||
App.api('/api/config/user-signatures').then(sigs => {
|
||||
if (sigs && sigs.length > 0) {
|
||||
activeSignature = sigs.find(s => s.is_default) || sigs[0];
|
||||
}
|
||||
}).catch(() => {});
|
||||
} catch (_) {}
|
||||
|
||||
const toolSelectBtn = document.getElementById('pdf-tool-select');
|
||||
const toolDrawBtn = document.getElementById('pdf-tool-draw');
|
||||
const toolTextBtn = document.getElementById('pdf-tool-text');
|
||||
const toolHighlightBtn = document.getElementById('pdf-tool-highlight');
|
||||
const toolShapeBtn = document.getElementById('pdf-tool-shape');
|
||||
const toolSigBtn = document.getElementById('pdf-tool-signature');
|
||||
const shapeControls = document.getElementById('pdf-shape-controls');
|
||||
const colorPicker = document.getElementById('pdf-color-picker');
|
||||
const toolClearBtn = document.getElementById('pdf-tool-clear');
|
||||
@@ -556,7 +618,7 @@ const TicketDetailView = {
|
||||
|
||||
const setActiveTool = (tool, activeBtn) => {
|
||||
currentTool = tool;
|
||||
const allToolBtns = [toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, document.getElementById('pdf-tool-select')];
|
||||
const allToolBtns = [toolSelectBtn, toolDrawBtn, toolTextBtn, toolHighlightBtn, toolShapeBtn, toolSigBtn];
|
||||
allToolBtns.forEach(b => {
|
||||
if (b) {
|
||||
b.style.background = 'transparent';
|
||||
@@ -570,11 +632,127 @@ const TicketDetailView = {
|
||||
if (shapeControls) {
|
||||
shapeControls.style.display = tool === 'shape' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Adjust pointer-events for text selection layer vs annotation canvas
|
||||
for (let num = 1; num <= totalPages; num++) {
|
||||
const dCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`);
|
||||
const tLayer = document.getElementById(`pdf-text-layer-page-${num}`);
|
||||
if (dCanvas) {
|
||||
dCanvas.style.pointerEvents = tool === 'select' ? 'none' : 'auto';
|
||||
}
|
||||
if (tLayer) {
|
||||
tLayer.style.pointerEvents = tool === 'select' ? 'auto' : 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (toolSelectBtn) toolSelectBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('select', toolSelectBtn); };
|
||||
if (toolDrawBtn) toolDrawBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('draw', toolDrawBtn); };
|
||||
if (toolTextBtn) toolTextBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('text', toolTextBtn); };
|
||||
if (toolHighlightBtn) toolHighlightBtn.onclick = (e) => { e.stopPropagation(); setActiveTool('highlight', toolHighlightBtn); };
|
||||
if (toolSigBtn) {
|
||||
toolSigBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
setActiveTool('signature', toolSigBtn);
|
||||
|
||||
let sigPopup = document.getElementById('pdf-sig-picker-popup');
|
||||
if (sigPopup) {
|
||||
sigPopup.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
let allSignatures = [];
|
||||
try {
|
||||
allSignatures = await App.api('/api/config/user-signatures');
|
||||
} catch (_) {}
|
||||
|
||||
if (!activeSignature && allSignatures.length > 0) {
|
||||
activeSignature = allSignatures.find(s => s.is_default) || allSignatures[0];
|
||||
}
|
||||
|
||||
const btnRect = toolSigBtn.getBoundingClientRect();
|
||||
sigPopup = document.createElement('div');
|
||||
sigPopup.id = 'pdf-sig-picker-popup';
|
||||
sigPopup.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 20000;
|
||||
background: #0f172a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.8);
|
||||
color: #ffffff;
|
||||
font-size: 0.78rem;
|
||||
min-width: 240px;
|
||||
max-height: 290px;
|
||||
overflow-y: auto;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
sigPopup.innerHTML = `
|
||||
<div style="font-weight: 700; margin-bottom: 6px; color: var(--accent-primary); border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>✍️ Seleziona Firma da Apporre</span>
|
||||
<span style="cursor:pointer; opacity:0.7;" id="sig-popup-close">✕</span>
|
||||
</div>
|
||||
|
||||
${allSignatures.length > 0 ? `
|
||||
<div style="font-size:0.72rem; color:#94a3b8; margin-bottom:6px;">Clicca per scegliere la firma da timbrare:</div>
|
||||
<div style="display:flex; flex-direction:column; gap:4px;">
|
||||
${allSignatures.map(s => `
|
||||
<button class="sig-picker-item ${activeSignature && activeSignature.id === s.id ? 'selected' : ''}" data-id="${s.id}" style="display:flex; align-items:center; gap:8px; width:100%; background:${activeSignature && activeSignature.id === s.id ? 'var(--accent-primary)' : 'rgba(255,255,255,0.08)'}; border:1px solid rgba(255,255,255,0.15); color:#fff; padding:6px 8px; border-radius:4px; cursor:pointer; text-align:left; font-size:0.76rem;">
|
||||
<img src="${s.image_data}" style="width:32px; height:22px; object-fit:contain; background:#fff; border-radius:2px; padding:1px; flex-shrink:0;" />
|
||||
<span style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${App.escapeHtml(s.name)}</span>
|
||||
${s.is_default ? '<span style="font-size:0.68rem; color:#fde047; font-weight:bold;">⭐ Predefinita</span>' : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : `
|
||||
<div style="font-size:0.74rem; color:#94a3b8; font-style:italic; padding:8px 0;">
|
||||
Nessuna firma salvata nel database.<br>Puoi caricarne su <strong>Configurazioni Utente</strong>.
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
|
||||
document.body.appendChild(sigPopup);
|
||||
|
||||
const sRect = sigPopup.getBoundingClientRect();
|
||||
const sWinW = window.innerWidth;
|
||||
const sWinH = window.innerHeight;
|
||||
let sLeft = btnRect.left;
|
||||
let sTop = btnRect.bottom + 6;
|
||||
if (sLeft + sRect.width > sWinW - 12) sLeft = sWinW - sRect.width - 12;
|
||||
if (sLeft < 12) sLeft = 12;
|
||||
if (sTop + sRect.height > sWinH - 12) {
|
||||
sTop = btnRect.top - sRect.height - 6;
|
||||
if (sTop < 12) sTop = sWinH - sRect.height - 12;
|
||||
}
|
||||
sigPopup.style.left = `${sLeft}px`;
|
||||
sigPopup.style.top = `${sTop}px`;
|
||||
|
||||
const closeSigPopup = () => { if (sigPopup) sigPopup.remove(); };
|
||||
sigPopup.querySelector('#sig-popup-close').onclick = closeSigPopup;
|
||||
|
||||
sigPopup.querySelectorAll('.sig-picker-item').forEach(btn => {
|
||||
btn.onclick = (eBtn) => {
|
||||
eBtn.stopPropagation();
|
||||
const found = allSignatures.find(s => s.id == btn.dataset.id);
|
||||
if (found) {
|
||||
activeSignature = found;
|
||||
Toast.success(`Firma "${found.name}" selezionata! Clicca sul PDF per apporla.`);
|
||||
}
|
||||
closeSigPopup();
|
||||
};
|
||||
});
|
||||
|
||||
const closeOnOutside = (evt) => {
|
||||
if (!sigPopup.contains(evt.target) && evt.target !== toolSigBtn) {
|
||||
closeSigPopup();
|
||||
document.removeEventListener('mousedown', closeOnOutside, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('mousedown', closeOnOutside, true), 50);
|
||||
};
|
||||
}
|
||||
if (toolShapeBtn) {
|
||||
toolShapeBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -620,9 +798,11 @@ const TicketDetailView = {
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size:0.72rem; color:#94a3b8; display:block; margin-bottom:4px;">Forme Base</label>
|
||||
<div style="display:flex; gap:4px;">
|
||||
<button class="base-shape-btn" data-type="rectangle" style="flex:1; padding:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.76rem;">🟦 Rettangolo</button>
|
||||
<button class="base-shape-btn" data-type="circle" style="flex:1; padding:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.76rem;">⚪ Cerchio</button>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:4px;">
|
||||
<button class="base-shape-btn" data-type="rectangle" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">🟦 Rettangolo</button>
|
||||
<button class="base-shape-btn" data-type="circle" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">⚪ Cerchio</button>
|
||||
<button class="base-shape-btn" data-type="line" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">📏 Linea</button>
|
||||
<button class="base-shape-btn" data-type="arrow" style="padding:5px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.15); border-radius:4px; cursor:pointer; font-size:0.75rem;">🏹 Freccia</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -646,6 +826,28 @@ const TicketDetailView = {
|
||||
|
||||
document.body.appendChild(shapePopup);
|
||||
|
||||
const sRect = shapePopup.getBoundingClientRect();
|
||||
const sWinW = window.innerWidth;
|
||||
const sWinH = window.innerHeight;
|
||||
|
||||
let sLeft = btnRect.left;
|
||||
let sTop = btnRect.bottom + 6;
|
||||
|
||||
if (sLeft + sRect.width > sWinW - 12) {
|
||||
sLeft = sWinW - sRect.width - 12;
|
||||
}
|
||||
if (sLeft < 12) {
|
||||
sLeft = 12;
|
||||
}
|
||||
|
||||
if (sTop + sRect.height > sWinH - 12) {
|
||||
sTop = btnRect.top - sRect.height - 6;
|
||||
if (sTop < 12) sTop = sWinH - sRect.height - 12;
|
||||
}
|
||||
|
||||
shapePopup.style.left = `${sLeft}px`;
|
||||
shapePopup.style.top = `${sTop}px`;
|
||||
|
||||
const closePopup = () => { if (shapePopup) shapePopup.remove(); };
|
||||
shapePopup.querySelector('#shape-popup-close').onclick = closePopup;
|
||||
|
||||
@@ -690,17 +892,83 @@ const TicketDetailView = {
|
||||
}
|
||||
if (colorPicker) colorPicker.onchange = (e) => { currentColor = e.target.value; };
|
||||
|
||||
// Create page containers, base canvases, and annotation overlay canvases
|
||||
const shapeBorderWidthEl = document.getElementById('pdf-shape-border-width');
|
||||
if (shapeBorderWidthEl) {
|
||||
shapeBorderWidthEl.oninput = (e) => {
|
||||
const val = parseInt(e.target.value, 10) || 1;
|
||||
if (selectedObject && selectedObject.type === 'shape') {
|
||||
selectedObject.borderWidth = val;
|
||||
redrawPageObjects();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const shapeBorderColorEl = document.getElementById('pdf-shape-border-color');
|
||||
if (shapeBorderColorEl) {
|
||||
shapeBorderColorEl.oninput = (e) => {
|
||||
if (selectedObject && selectedObject.type === 'shape') {
|
||||
selectedObject.borderColor = e.target.value;
|
||||
redrawPageObjects();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const shapeFillColorEl = document.getElementById('pdf-shape-fill-color');
|
||||
if (shapeFillColorEl) {
|
||||
shapeFillColorEl.oninput = (e) => {
|
||||
if (selectedObject && selectedObject.type === 'shape') {
|
||||
selectedObject.fillColor = e.target.value;
|
||||
redrawPageObjects();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for manual text layer rendering fallback
|
||||
const renderManualTextLayer = (textContent, textLayerDiv, viewport) => {
|
||||
textLayerDiv.innerHTML = '';
|
||||
if (!textContent || !textContent.items) return;
|
||||
textContent.items.forEach(item => {
|
||||
if (!item.str) return;
|
||||
const tx = window.pdfjsLib.Util.transform(viewport.transform, item.transform);
|
||||
const fontHeight = Math.hypot(tx[2], tx[3]);
|
||||
const span = document.createElement('span');
|
||||
span.textContent = item.str;
|
||||
span.style.cssText = `
|
||||
position: absolute;
|
||||
left: ${tx[4]}px;
|
||||
top: ${tx[5] - fontHeight}px;
|
||||
font-size: ${fontHeight}px;
|
||||
font-family: sans-serif;
|
||||
color: transparent;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
line-height: 1;
|
||||
`;
|
||||
if (tx[0] !== 1 || tx[1] !== 0) {
|
||||
const angle = Math.atan2(tx[1], tx[0]);
|
||||
if (angle !== 0) span.style.transform = `rotate(${angle}rad)`;
|
||||
}
|
||||
textLayerDiv.appendChild(span);
|
||||
});
|
||||
};
|
||||
|
||||
// Create page containers, base canvases, text layers, and annotation overlay canvases
|
||||
for (let num = 1; num <= totalPages; num++) {
|
||||
const pageContainer = document.createElement('div');
|
||||
pageContainer.className = 'pdf-page-container';
|
||||
pageContainer.dataset.pageNum = num;
|
||||
pageContainer.style.cssText = 'position: relative; margin-bottom: 16px; display: flex; flex-direction: column; align-items: center; user-select: none;';
|
||||
pageContainer.style.cssText = 'position: relative; margin-bottom: 16px; display: flex; flex-direction: column; align-items: center; user-select: text;';
|
||||
|
||||
const baseCanvas = document.createElement('canvas');
|
||||
baseCanvas.id = `pdf-canvas-page-${num}`;
|
||||
baseCanvas.style.cssText = 'box-shadow: 0 4px 20px rgba(0,0,0,0.4); border-radius: 4px; background: #ffffff; display: block;';
|
||||
|
||||
const textLayerDiv = document.createElement('div');
|
||||
textLayerDiv.id = `pdf-text-layer-page-${num}`;
|
||||
textLayerDiv.className = 'textLayer';
|
||||
textLayerDiv.style.cssText = 'position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-radius: 4px; z-index: 5; pointer-events: none; user-select: text; -webkit-user-select: text;';
|
||||
|
||||
const drawCanvas = document.createElement('canvas');
|
||||
drawCanvas.id = `pdf-draw-canvas-page-${num}`;
|
||||
drawCanvas.className = 'pdf-annotation-canvas';
|
||||
@@ -708,6 +976,7 @@ const TicketDetailView = {
|
||||
drawCanvas.style.cssText = 'position: absolute; top: 0; left: 50%; transform: translateX(-50%); border-radius: 4px; cursor: crosshair; z-index: 10; touch-action: none;';
|
||||
|
||||
pageContainer.appendChild(baseCanvas);
|
||||
pageContainer.appendChild(textLayerDiv);
|
||||
pageContainer.appendChild(drawCanvas);
|
||||
wrapper.appendChild(pageContainer);
|
||||
}
|
||||
@@ -720,12 +989,19 @@ const TicketDetailView = {
|
||||
const page = await pdfDoc.getPage(num);
|
||||
const viewport = page.getViewport({ scale: 1.25 });
|
||||
const baseCanvas = document.getElementById(`pdf-canvas-page-${num}`);
|
||||
const textLayerDiv = document.getElementById(`pdf-text-layer-page-${num}`);
|
||||
const drawCanvas = document.getElementById(`pdf-draw-canvas-page-${num}`);
|
||||
if (!baseCanvas || !drawCanvas) continue;
|
||||
|
||||
baseCanvas.height = viewport.height;
|
||||
baseCanvas.width = viewport.width;
|
||||
|
||||
if (textLayerDiv) {
|
||||
textLayerDiv.style.width = `${viewport.width}px`;
|
||||
textLayerDiv.style.height = `${viewport.height}px`;
|
||||
textLayerDiv.style.setProperty('--scale-factor', viewport.scale);
|
||||
}
|
||||
|
||||
drawCanvas.height = viewport.height;
|
||||
drawCanvas.width = viewport.width;
|
||||
|
||||
@@ -737,6 +1013,36 @@ const TicketDetailView = {
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
|
||||
if (textLayerDiv) {
|
||||
try {
|
||||
const textContent = await page.getTextContent();
|
||||
if (window.pdfjsLib.renderTextLayer) {
|
||||
const task = window.pdfjsLib.renderTextLayer({
|
||||
textContentSource: textContent,
|
||||
container: textLayerDiv,
|
||||
viewport: viewport,
|
||||
textDivs: []
|
||||
});
|
||||
if (task && task.promise) await task.promise;
|
||||
} else if (window.pdfjsLib.TextLayer) {
|
||||
const textLayer = new window.pdfjsLib.TextLayer({
|
||||
textContentSource: textContent,
|
||||
container: textLayerDiv,
|
||||
viewport: viewport
|
||||
});
|
||||
await textLayer.render();
|
||||
} else {
|
||||
renderManualTextLayer(textContent, textLayerDiv, viewport);
|
||||
}
|
||||
} catch (textErr) {
|
||||
console.warn('PDF text layer render note:', textErr);
|
||||
try {
|
||||
const textContent = await page.getTextContent();
|
||||
renderManualTextLayer(textContent, textLayerDiv, viewport);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup Object-Based Interactive Annotation System for each PDF page
|
||||
const drawCtx = drawCanvas.getContext('2d');
|
||||
|
||||
@@ -800,38 +1106,102 @@ const TicketDetailView = {
|
||||
const cx = obj.x + rx;
|
||||
const cy = obj.y + ry;
|
||||
drawCtx.ellipse(cx, cy, Math.abs(rx), Math.abs(ry), 0, 0, Math.PI * 2);
|
||||
if (obj.fillColor && obj.fillColor !== 'transparent') {
|
||||
drawCtx.fillStyle = obj.fillColor;
|
||||
drawCtx.fill();
|
||||
}
|
||||
if (obj.borderWidth > 0 && obj.borderColor) {
|
||||
drawCtx.lineWidth = obj.borderWidth;
|
||||
drawCtx.strokeStyle = obj.borderColor;
|
||||
drawCtx.stroke();
|
||||
}
|
||||
} else if (obj.shapeType === 'line' || obj.shapeType === 'arrow') {
|
||||
const startX = obj.x;
|
||||
const startY = obj.y;
|
||||
const endX = obj.x + obj.width;
|
||||
const endY = obj.y + obj.height;
|
||||
const bw = obj.borderWidth || 3;
|
||||
const strokeColor = obj.borderColor || obj.fillColor || '#1e3a8a';
|
||||
|
||||
if (obj.shapeType === 'arrow') {
|
||||
const angle = Math.atan2(endY - startY, endX - startX);
|
||||
const headLength = Math.max(14, bw * 3.5);
|
||||
const shorten = headLength * 0.6; // Shorten main line stem so round lineCap doesn't stick out past arrow tip
|
||||
|
||||
const stemEndX = endX - shorten * Math.cos(angle);
|
||||
const stemEndY = endY - shorten * Math.sin(angle);
|
||||
|
||||
drawCtx.moveTo(startX, startY);
|
||||
drawCtx.lineTo(stemEndX, stemEndY);
|
||||
drawCtx.lineWidth = bw;
|
||||
drawCtx.strokeStyle = strokeColor;
|
||||
drawCtx.lineCap = 'round';
|
||||
drawCtx.stroke();
|
||||
|
||||
drawCtx.beginPath();
|
||||
drawCtx.moveTo(endX, endY);
|
||||
drawCtx.lineTo(
|
||||
endX - headLength * Math.cos(angle - Math.PI / 6),
|
||||
endY - headLength * Math.sin(angle - Math.PI / 6)
|
||||
);
|
||||
drawCtx.lineTo(
|
||||
endX - headLength * Math.cos(angle + Math.PI / 6),
|
||||
endY - headLength * Math.sin(angle + Math.PI / 6)
|
||||
);
|
||||
drawCtx.closePath();
|
||||
drawCtx.fillStyle = strokeColor;
|
||||
drawCtx.fill();
|
||||
} else {
|
||||
drawCtx.moveTo(startX, startY);
|
||||
drawCtx.lineTo(endX, endY);
|
||||
drawCtx.lineWidth = bw;
|
||||
drawCtx.strokeStyle = strokeColor;
|
||||
drawCtx.lineCap = 'round';
|
||||
drawCtx.stroke();
|
||||
}
|
||||
} else {
|
||||
drawCtx.rect(obj.x, obj.y, obj.width, obj.height);
|
||||
}
|
||||
if (obj.fillColor && obj.fillColor !== 'transparent') {
|
||||
drawCtx.fillStyle = obj.fillColor;
|
||||
drawCtx.fill();
|
||||
}
|
||||
if (obj.borderWidth > 0 && obj.borderColor) {
|
||||
drawCtx.lineWidth = obj.borderWidth;
|
||||
drawCtx.strokeStyle = obj.borderColor;
|
||||
drawCtx.stroke();
|
||||
if (obj.fillColor && obj.fillColor !== 'transparent') {
|
||||
drawCtx.fillStyle = obj.fillColor;
|
||||
drawCtx.fill();
|
||||
}
|
||||
if (obj.borderWidth > 0 && obj.borderColor) {
|
||||
drawCtx.lineWidth = obj.borderWidth;
|
||||
drawCtx.strokeStyle = obj.borderColor;
|
||||
drawCtx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
drawCtx.restore();
|
||||
|
||||
// Draw selection box & resize handles if selected
|
||||
// Draw selection box & resize/endpoint handles if selected
|
||||
if (selectedObject === obj) {
|
||||
drawCtx.save();
|
||||
drawCtx.strokeStyle = '#3b82f6';
|
||||
drawCtx.lineWidth = 1.5;
|
||||
drawCtx.setLineDash([4, 4]);
|
||||
drawCtx.strokeRect(obj.x - 4, obj.y - 4, obj.width + 8, obj.height + 8);
|
||||
drawCtx.setLineDash([]);
|
||||
|
||||
// Handles at corners and midpoints
|
||||
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
|
||||
// For line and arrow, draw guide line between origin and endpoint
|
||||
drawCtx.setLineDash([3, 3]);
|
||||
drawCtx.beginPath();
|
||||
drawCtx.moveTo(obj.x, obj.y);
|
||||
drawCtx.lineTo(obj.x + obj.width, obj.y + obj.height);
|
||||
drawCtx.stroke();
|
||||
drawCtx.setLineDash([]);
|
||||
} else {
|
||||
drawCtx.setLineDash([4, 4]);
|
||||
drawCtx.strokeRect(obj.x - 4, obj.y - 4, obj.width + 8, obj.height + 8);
|
||||
drawCtx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Handles at corners/endpoints
|
||||
const handles = getObjectHandles(obj);
|
||||
handles.forEach(h => {
|
||||
drawCtx.fillStyle = '#ffffff';
|
||||
drawCtx.strokeStyle = '#2563eb';
|
||||
drawCtx.lineWidth = 1.5;
|
||||
drawCtx.beginPath();
|
||||
drawCtx.arc(h.x, h.y, 4.5, 0, Math.PI * 2);
|
||||
drawCtx.arc(h.x, h.y, 5, 0, Math.PI * 2);
|
||||
drawCtx.fill();
|
||||
drawCtx.stroke();
|
||||
});
|
||||
@@ -845,6 +1215,16 @@ const TicketDetailView = {
|
||||
const y = obj.y;
|
||||
const w = obj.width;
|
||||
const h = obj.height;
|
||||
|
||||
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
|
||||
// Handle 0: Start / Origin point (P1), Handle 1: Midpoint, Handle 2: End / Target point (P2)
|
||||
return [
|
||||
{ x: x, y: y }, // 0: Punto di Origine
|
||||
{ x: x + w / 2, y: y + h / 2 }, // 1: Punto Centrale
|
||||
{ x: x + w, y: y + h } // 2: Punto di Arrivo
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ x: x, y: y }, // 0: Top-Left
|
||||
{ x: x + w / 2, y: y }, // 1: Top-Center
|
||||
@@ -1005,6 +1385,31 @@ const TicketDetailView = {
|
||||
|
||||
document.body.appendChild(menuOverlay);
|
||||
|
||||
// Clamp positioning to prevent overflowing window boundaries
|
||||
const menuRect = menuOverlay.getBoundingClientRect();
|
||||
const winW = window.innerWidth;
|
||||
const winH = window.innerHeight;
|
||||
|
||||
let finalLeft = e.clientX;
|
||||
let finalTop = e.clientY;
|
||||
|
||||
if (finalLeft + menuRect.width > winW - 12) {
|
||||
finalLeft = winW - menuRect.width - 12;
|
||||
}
|
||||
if (finalLeft < 12) {
|
||||
finalLeft = 12;
|
||||
}
|
||||
|
||||
if (finalTop + menuRect.height > winH - 12) {
|
||||
finalTop = winH - menuRect.height - 12;
|
||||
}
|
||||
if (finalTop < 12) {
|
||||
finalTop = 12;
|
||||
}
|
||||
|
||||
menuOverlay.style.left = `${finalLeft}px`;
|
||||
menuOverlay.style.top = `${finalTop}px`;
|
||||
|
||||
// Menu Event Listeners
|
||||
const closeCtx = () => { if (menuOverlay) menuOverlay.remove(); };
|
||||
menuOverlay.querySelector('#ctx-close').onclick = closeCtx;
|
||||
@@ -1043,7 +1448,20 @@ const TicketDetailView = {
|
||||
if (sizeInput) {
|
||||
sizeInput.oninput = (eVal) => {
|
||||
eVal.stopPropagation();
|
||||
currentBrushSize = parseInt(eVal.target.value, 10);
|
||||
const val = parseInt(eVal.target.value, 10);
|
||||
currentBrushSize = val;
|
||||
|
||||
// Update shape border width input in toolbar if shape tool is active
|
||||
const shapeBorderWidthEl = document.getElementById('pdf-shape-border-width');
|
||||
if (shapeBorderWidthEl) {
|
||||
shapeBorderWidthEl.value = val;
|
||||
}
|
||||
|
||||
// If a shape object is currently selected, update its borderWidth live
|
||||
if (selectedObject && selectedObject.type === 'shape') {
|
||||
selectedObject.borderWidth = val;
|
||||
redrawPageObjects();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1174,6 +1592,33 @@ const TicketDetailView = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTool === 'signature') {
|
||||
if (activeSignature) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const targetWidth = Math.min(220, img.width || 180);
|
||||
const targetHeight = (img.height / img.width) * targetWidth || 80;
|
||||
const newObj = {
|
||||
id: Date.now(),
|
||||
type: 'image',
|
||||
img: img,
|
||||
x: x - targetWidth / 2,
|
||||
y: y - targetHeight / 2,
|
||||
width: targetWidth,
|
||||
height: targetHeight
|
||||
};
|
||||
pageObjects.push(newObj);
|
||||
selectedObject = newObj;
|
||||
redrawPageObjects();
|
||||
Toast.success(`Firma "${activeSignature.name}" apposta sul PDF!`);
|
||||
};
|
||||
img.src = activeSignature.image_data;
|
||||
} else {
|
||||
Toast.warning('Nessuna firma selezionata. Clicca sul pulsante ✍️ Firma per sceglierne una.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const openInlineTextEditor = (clientX, clientY, initialX, initialY, existingTextObj = null) => {
|
||||
let inlineInput = document.getElementById('pdf-inline-text-input');
|
||||
if (inlineInput) inlineInput.remove();
|
||||
@@ -1297,6 +1742,28 @@ const TicketDetailView = {
|
||||
const borderColor = borderColorEl ? borderColorEl.value : '#1e3a8a';
|
||||
const borderWidth = borderWidthEl ? parseInt(borderWidthEl.value, 10) : 3;
|
||||
|
||||
if (shapeType === 'line' || shapeType === 'arrow') {
|
||||
// Interactive click-and-drag line / arrow creation
|
||||
const newShapeObj = {
|
||||
id: Date.now(),
|
||||
type: 'shape',
|
||||
shapeType: shapeType,
|
||||
x: x,
|
||||
y: y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fillColor: fillColor,
|
||||
borderColor: borderColor,
|
||||
borderWidth: borderWidth
|
||||
};
|
||||
pageObjects.push(newShapeObj);
|
||||
selectedObject = newShapeObj;
|
||||
isResizingObj = true;
|
||||
resizeHandleIndex = 2; // Dragging target/endpoint handle
|
||||
redrawPageObjects();
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultW = 120;
|
||||
const defaultH = 80;
|
||||
|
||||
@@ -1356,7 +1823,20 @@ const TicketDetailView = {
|
||||
const oldW = obj.width || 1;
|
||||
const oldH = obj.height || 1;
|
||||
|
||||
if (resizeHandleIndex === 0) { // Top-Left
|
||||
if (obj.type === 'shape' && (obj.shapeType === 'line' || obj.shapeType === 'arrow')) {
|
||||
// Line / Arrow endpoint dragging
|
||||
if (resizeHandleIndex === 0) { // Origin (P1: x, y)
|
||||
const endX = obj.x + obj.width;
|
||||
const endY = obj.y + obj.height;
|
||||
obj.x = x;
|
||||
obj.y = y;
|
||||
obj.width = endX - x;
|
||||
obj.height = endY - y;
|
||||
} else if (resizeHandleIndex === 2) { // Target / Endpoint (P2: x+width, y+height)
|
||||
obj.width = x - obj.x;
|
||||
obj.height = y - obj.y;
|
||||
}
|
||||
} else if (resizeHandleIndex === 0) { // Top-Left
|
||||
const oldRight = obj.x + obj.width;
|
||||
const oldBottom = obj.y + obj.height;
|
||||
obj.x = Math.min(x, oldRight - 10);
|
||||
@@ -1440,6 +1920,34 @@ const TicketDetailView = {
|
||||
}
|
||||
const hoverObj = hitTestObject(x, y);
|
||||
drawCanvas.style.cursor = hoverObj ? 'move' : (currentTool === 'select' ? 'default' : 'crosshair');
|
||||
|
||||
// Live preview ghost for active signature under cursor
|
||||
if (currentTool === 'signature' && activeSignature && !isDrawingStroke && !isDraggingObj && !isResizingObj) {
|
||||
redrawPageObjects();
|
||||
if (!activeSignature._imgCache || activeSignature._imgCache.src !== activeSignature.image_data) {
|
||||
const cachedImg = new Image();
|
||||
cachedImg.src = activeSignature.image_data;
|
||||
activeSignature._imgCache = cachedImg;
|
||||
}
|
||||
const img = activeSignature._imgCache;
|
||||
if (img && img.complete && img.naturalWidth !== 0) {
|
||||
const targetW = Math.min(220, img.width || 180);
|
||||
const targetH = (img.height / img.width) * targetW || 80;
|
||||
const ghostX = x - targetW / 2;
|
||||
const ghostY = y - targetH / 2;
|
||||
|
||||
drawCtx.save();
|
||||
drawCtx.globalAlpha = 0.6;
|
||||
drawCtx.drawImage(img, ghostX, ghostY, targetW, targetH);
|
||||
drawCtx.strokeStyle = '#3b82f6';
|
||||
drawCtx.lineWidth = 1.5;
|
||||
drawCtx.setLineDash([4, 4]);
|
||||
drawCtx.strokeRect(ghostX - 2, ghostY - 2, targetW + 4, targetH + 4);
|
||||
drawCtx.restore();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDrawingStroke) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1485,7 +1993,10 @@ const TicketDetailView = {
|
||||
};
|
||||
|
||||
drawCanvas.onmouseup = stopDrawing;
|
||||
drawCanvas.onmouseleave = stopDrawing;
|
||||
drawCanvas.onmouseleave = (e) => {
|
||||
stopDrawing(e);
|
||||
redrawPageObjects();
|
||||
};
|
||||
|
||||
} catch (pErr) {
|
||||
console.warn(`Failed rendering PDF page ${num}`, pErr);
|
||||
@@ -1973,13 +2484,46 @@ const TicketDetailView = {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Pulsante Info (i) a destra del cestino con popover al passaggio del mouse -->
|
||||
<div class="article-info-popover-wrapper" style="position: relative; display: inline-flex; margin-left: var(--space-xs); padding: 15px 60px 45px 20px; margin-top: -15px; margin-bottom: -45px; margin-right: -60px; margin-left: -20px;">
|
||||
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; margin-left:var(--space-xs); display:inline-flex; align-items:center; justify-content:center; color:var(--accent-secondary); transition:all 0.15s ease; opacity:1;" title="Rispondi via email (Quota questo articolo)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
|
||||
${a.time_unit ? `
|
||||
<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ <span class="time-value-display">${parseFloat(a.time_unit)}</span> min</span>
|
||||
` : ''}
|
||||
<button class="retrodate-btn-edit btn-edit-article-time" style="background:none; border:none; cursor:pointer; font-size:0.75rem; padding: 2px 4px; display:inline-flex; align-items:center;" title="${a.time_unit ? 'Modifica Tempo' : 'Aggiungi Tempo'}">
|
||||
${a.time_unit ? '✏️' : '⏱+✏️'}
|
||||
</button>
|
||||
<div class="time-editor-popover" style="display:none; position:absolute; right:0; top: 100%; background:var(--bg-card); border:1px solid var(--border-subtle); padding:var(--space-xs); border-radius:var(--radius-sm); z-index:100; box-shadow:var(--shadow-md); margin-top:4px;">
|
||||
<input type="number" step="any" min="0" class="form-input input-article-time" value="${a.time_unit ? parseFloat(a.time_unit) : 0}" style="width:70px; height:24px; padding:2px 6px; font-size:0.8rem; margin-bottom:4px; display:block;" />
|
||||
<div style="display:flex; gap:4px; justify-content:flex-end;">
|
||||
<button class="btn btn-primary btn-sm btn-save-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✓</button>
|
||||
<button class="btn btn-ghost btn-sm btn-cancel-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="retrodate-container" data-article-id="${a.article_id}">
|
||||
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
||||
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
|
||||
<div class="retrodate-editor" style="display:none;">
|
||||
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
|
||||
<div class="retrodate-actions">
|
||||
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
|
||||
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pulsante Info (i) a destra della matitina dell'ora/data con popover -->
|
||||
<div class="article-info-popover-wrapper" style="position: relative; display: inline-flex;">
|
||||
<button class="btn-article-info" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; display:inline-flex; align-items:center; justify-content:center; color:var(--accent-primary); transition:all 0.15s ease; font-weight:700; font-size:0.75rem; width:22px; height:22px; position:relative; z-index:2;" title="Dettagli e Persone Email">
|
||||
ℹ️
|
||||
</button>
|
||||
|
||||
<div class="article-info-popover" style="position: absolute; top: calc(100% - 25px); left: 10px; width: 340px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; box-shadow: var(--shadow-lg); z-index: 120; display: none; text-align: left;">
|
||||
<div class="article-info-popover" style="position: absolute; top: calc(100% + 4px); right: 0; left: auto; width: 340px; background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 12px; font-size: 0.78rem; box-shadow: var(--shadow-lg); z-index: 120; display: none; text-align: left;">
|
||||
<div style="font-weight: 700; color: var(--text-secondary); margin-bottom: 8px; border-bottom: 1px solid var(--border-subtle); padding-bottom: 4px; display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem;">
|
||||
<span style="display: flex; align-items: center; gap: 4px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:var(--accent-primary);"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
@@ -2039,38 +2583,6 @@ const TicketDetailView = {
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-email-article" data-article-id="${a.article_id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; padding:3px 6px; margin-left:var(--space-xs); display:inline-flex; align-items:center; justify-content:center; color:var(--accent-secondary); transition:all 0.15s ease; opacity:1;" title="Rispondi via email (Quota questo articolo)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px; height:14px;"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex; gap: var(--space-sm); align-items:center;">
|
||||
<div class="time-edit-container" data-article-id="${a.article_id}" style="display:inline-flex; align-items:center; gap:4px; position:relative;">
|
||||
${a.time_unit ? `
|
||||
<span class="badge" style="background:var(--info-bg);color:var(--info);font-size:0.75rem;padding:2px 8px;border-radius:4px;">⏱ <span class="time-value-display">${parseFloat(a.time_unit)}</span> min</span>
|
||||
` : ''}
|
||||
<button class="retrodate-btn-edit btn-edit-article-time" style="background:none; border:none; cursor:pointer; font-size:0.75rem; padding: 2px 4px; display:inline-flex; align-items:center;" title="${a.time_unit ? 'Modifica Tempo' : 'Aggiungi Tempo'}">
|
||||
${a.time_unit ? '✏️' : '⏱+✏️'}
|
||||
</button>
|
||||
<div class="time-editor-popover" style="display:none; position:absolute; right:0; top: 100%; background:var(--bg-card); border:1px solid var(--border-subtle); padding:var(--space-xs); border-radius:var(--radius-sm); z-index:100; box-shadow:var(--shadow-md); margin-top:4px;">
|
||||
<input type="number" step="any" min="0" class="form-input input-article-time" value="${a.time_unit ? parseFloat(a.time_unit) : 0}" style="width:70px; height:24px; padding:2px 6px; font-size:0.8rem; margin-bottom:4px; display:block;" />
|
||||
<div style="display:flex; gap:4px; justify-content:flex-end;">
|
||||
<button class="btn btn-primary btn-sm btn-save-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✓</button>
|
||||
<button class="btn btn-ghost btn-sm btn-cancel-article-time" style="height:20px; padding:0 6px; font-size:0.75rem; line-height:20px;">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="retrodate-container" data-article-id="${a.article_id}">
|
||||
<span class="article-time">${App.formatDateTime(a.create_time)}</span>
|
||||
<button class="retrodate-btn-edit btn-edit-article-date" title="Retrodata Articolo">✏️</button>
|
||||
<div class="retrodate-editor" style="display:none;">
|
||||
<input type="datetime-local" class="retrodate-input input-article-date" value="${localISO(a.create_time)}" />
|
||||
<div class="retrodate-actions">
|
||||
<button class="retrodate-btn-action save btn-save-article-date" title="Salva">✓</button>
|
||||
<button class="retrodate-btn-action cancel btn-cancel-article-date" title="Annulla">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${a.a_subject ? `<div class="article-subject" style="font-weight:700; font-size:0.95rem; color:var(--text-primary); margin-bottom:var(--space-xs);">${App.escapeHtml(a.a_subject)}</div>` : ''}
|
||||
|
||||
@@ -9,6 +9,8 @@ const TimeAccountingView = {
|
||||
selectedDate: null,
|
||||
selectedArticleId: null,
|
||||
summaryData: null,
|
||||
viewMode: 'list', // 'list' | 'calendar'
|
||||
showAllTime: false,
|
||||
showNonWorkingDays: localStorage.getItem('otrs_ta_show_non_working_days') !== 'false',
|
||||
|
||||
formatLocalISO(d) {
|
||||
@@ -45,6 +47,11 @@ const TimeAccountingView = {
|
||||
agent_id: this.selectedAgentId,
|
||||
date: this.selectedDate,
|
||||
});
|
||||
if (this.showAllTime) {
|
||||
params.set('range', 'all');
|
||||
} else if (this.viewMode === 'calendar') {
|
||||
params.set('range', 'month');
|
||||
}
|
||||
|
||||
let data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
|
||||
|
||||
@@ -73,9 +80,14 @@ const TimeAccountingView = {
|
||||
this.selectedArticleId = articles.length > 0 ? articles[0].article_id : null;
|
||||
}
|
||||
|
||||
const currentScrollX = window.scrollX;
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
container.innerHTML = this.buildHtml(data);
|
||||
this.bindEvents();
|
||||
|
||||
window.scrollTo(currentScrollX, currentScrollY);
|
||||
|
||||
// Smooth scroll active day pill into center view
|
||||
setTimeout(() => {
|
||||
const activePill = container.querySelector('.ta-day-pill.active');
|
||||
@@ -132,10 +144,13 @@ const TimeAccountingView = {
|
||||
return `
|
||||
<div class="view-header" style="padding:var(--space-md) var(--space-xl); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-sm); border-bottom:1px solid var(--border-subtle);">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px; height:20px; color:var(--accent-primary);">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<button id="ta-toggle-view-mode" class="btn btn-ghost btn-sm" style="padding:4px 8px; display:inline-flex; align-items:center; justify-content:center; border-radius:6px; background:var(--bg-tertiary); color:var(--accent-primary); border:1px solid var(--border-subtle); cursor:pointer;" title="${this.viewMode === 'list' ? 'Passa alla vista Calendario Mese' : 'Passa alla vista Dettaglio Elenco'}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px; height:20px;">
|
||||
${this.viewMode === 'calendar'
|
||||
? '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>'
|
||||
: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>'}
|
||||
</svg>
|
||||
</button>
|
||||
<span style="font-weight:600; font-size:1rem;">Storico consuntivazione</span>
|
||||
<span class="badge" style="background:${headerBadgeBg}; color:#fff; font-size:0.78rem; padding:3px 10px; border-radius:12px;">
|
||||
${headerBadgeText}
|
||||
@@ -170,33 +185,35 @@ const TimeAccountingView = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interactive Day Pills Strip with Target-based Color Coding -->
|
||||
${this.buildDayStrip(data)}
|
||||
${this.viewMode === 'calendar' ? this.buildCalendarGrid(data) : `
|
||||
<!-- Interactive Day Pills Strip -->
|
||||
${this.buildDayStrip(data)}
|
||||
|
||||
<!-- Two-column Master/Detail Layout -->
|
||||
<div style="display:flex; gap:var(--space-md); padding:var(--space-md) var(--space-xl); align-items:flex-start; min-height:calc(100vh - 280px);">
|
||||
<!-- Two-column Master/Detail Layout -->
|
||||
<div style="display:flex; gap:var(--space-md); padding:var(--space-md) var(--space-xl); align-items:flex-start; min-height:calc(100vh - 280px);">
|
||||
|
||||
<!-- Left Side: List of Articles -->
|
||||
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-sm);">
|
||||
${articles.length === 0 ? `
|
||||
<div class="empty-state" style="padding:var(--space-2xl); text-align:center; color:var(--text-muted); background:var(--bg-card); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px; height:48px; margin:0 auto var(--space-md); display:block; opacity:0.4;">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<p style="font-size:0.9rem; font-weight:500;">Nessuna nota o email trovata in questa data per l'agente selezionato.</p>
|
||||
</div>
|
||||
` : articles.map(a => this.buildArticleCard(a)).join('')}
|
||||
<!-- Left Side: List of Articles -->
|
||||
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--space-sm);">
|
||||
${articles.length === 0 ? `
|
||||
<div class="empty-state" style="padding:var(--space-2xl); text-align:center; color:var(--text-muted); background:var(--bg-card); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:48px; height:48px; margin:0 auto var(--space-md); display:block; opacity:0.4;">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<p style="font-size:0.9rem; font-weight:500;">Nessuna nota o email trovata in questa data per l'agente selezionato.</p>
|
||||
</div>
|
||||
` : articles.map(a => this.buildArticleCard(a)).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Details & Actions Panel -->
|
||||
<div style="width:380px; flex-shrink:0; position:sticky; top:20px;">
|
||||
${selectedArticle ? this.buildDetailPanel(selectedArticle) : `
|
||||
<div class="card" style="padding:var(--space-lg); text-align:center; color:var(--text-muted); font-size:0.85rem;">
|
||||
Seleziona una nota o email a sinistra per gestirne il tempo o retrodatare.
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Details & Actions Panel -->
|
||||
<div style="width:380px; flex-shrink:0; position:sticky; top:20px;">
|
||||
${selectedArticle ? this.buildDetailPanel(selectedArticle) : `
|
||||
<div class="card" style="padding:var(--space-lg); text-align:center; color:var(--text-muted); font-size:0.85rem;">
|
||||
Seleziona una nota o email a sinistra per gestirne il tempo o retrodatare.
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
},
|
||||
|
||||
@@ -336,6 +353,193 @@ const TimeAccountingView = {
|
||||
`;
|
||||
},
|
||||
|
||||
buildCalendarGrid(data) {
|
||||
const todayStr = this.formatLocalISO(new Date());
|
||||
const dailyTotals = (data && data.daily_totals) || {};
|
||||
const targetMins = (data && data.target_minutes) || 480;
|
||||
const workDays = (data && data.work_days) || [1, 2, 3, 4, 5];
|
||||
const monthNames = ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'];
|
||||
const headerDays = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
|
||||
|
||||
// If showAllTime is true, render all months present in dailyTotals or at least current month down to earliest month
|
||||
let monthsToRender = [];
|
||||
|
||||
if (this.showAllTime) {
|
||||
const dates = Object.keys(dailyTotals).sort();
|
||||
let startYear, startMonth;
|
||||
if (dates.length > 0) {
|
||||
const [y, m] = dates[0].split('-').map(Number);
|
||||
startYear = y;
|
||||
startMonth = m - 1;
|
||||
} else {
|
||||
const now = new Date();
|
||||
startYear = now.getFullYear();
|
||||
startMonth = now.getMonth();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const endYear = now.getFullYear();
|
||||
const endMonth = now.getMonth();
|
||||
|
||||
// Loop month by month from earliest to current/selected
|
||||
let curr = new Date(startYear, startMonth, 1);
|
||||
const end = new Date(endYear, endMonth, 1);
|
||||
while (curr <= end) {
|
||||
monthsToRender.push({ year: curr.getFullYear(), month: curr.getMonth() });
|
||||
curr.setMonth(curr.getMonth() + 1);
|
||||
}
|
||||
// Reverse so newest months appear first
|
||||
monthsToRender.reverse();
|
||||
} else {
|
||||
const selectedParts = this.selectedDate.split('-').map(Number);
|
||||
monthsToRender.push({ year: selectedParts[0], month: selectedParts[1] - 1 });
|
||||
}
|
||||
|
||||
const renderSingleMonthGrid = (mYear, mMonth) => {
|
||||
const firstDay = new Date(mYear, mMonth, 1);
|
||||
const lastDay = new Date(mYear, mMonth + 1, 0);
|
||||
|
||||
let startDayOfWeek = firstDay.getDay() - 1;
|
||||
if (startDayOfWeek === -1) startDayOfWeek = 6;
|
||||
|
||||
const cells = [];
|
||||
for (let i = 0; i < startDayOfWeek; i++) {
|
||||
cells.push(`<div style="background:var(--bg-tertiary); opacity:0.3; border-radius:var(--radius-md); min-height:85px; border:1px dashed var(--border-subtle);"></div>`);
|
||||
}
|
||||
|
||||
const daysInMonth = lastDay.getDate();
|
||||
for (let dayNum = 1; dayNum <= daysInMonth; dayNum++) {
|
||||
const d = new Date(mYear, mMonth, dayNum);
|
||||
const isoStr = this.formatLocalISO(d);
|
||||
const dayOfWeek = d.getDay();
|
||||
const isWorkDay = workDays.includes(dayOfWeek);
|
||||
const isSelected = isoStr === this.selectedDate;
|
||||
const isToday = isoStr === todayStr;
|
||||
|
||||
const dayMins = Math.round(dailyTotals[isoStr] || 0);
|
||||
const formattedTime = this.formatPillTime(dayMins);
|
||||
const isTargetReached = dayMins >= targetMins;
|
||||
|
||||
let bgStyle = 'var(--bg-card)';
|
||||
let borderStyle = '1px solid var(--border-subtle)';
|
||||
let textColor = 'var(--text-primary)';
|
||||
let badgeBg = 'var(--bg-tertiary)';
|
||||
let badgeColor = 'var(--text-muted)';
|
||||
|
||||
if (!isWorkDay) {
|
||||
if (isSelected) {
|
||||
bgStyle = 'var(--accent-primary)';
|
||||
borderStyle = '2px solid var(--accent-primary)';
|
||||
textColor = '#ffffff';
|
||||
badgeBg = 'rgba(255,255,255,0.25)';
|
||||
badgeColor = '#ffffff';
|
||||
} else if (isToday) {
|
||||
bgStyle = 'rgba(79, 70, 229, 0.12)';
|
||||
textColor = 'var(--accent-primary)';
|
||||
}
|
||||
} else {
|
||||
if (isTargetReached) {
|
||||
if (isSelected) {
|
||||
bgStyle = '#15803d';
|
||||
borderStyle = '2px solid #166534';
|
||||
textColor = '#ffffff';
|
||||
badgeBg = 'rgba(255,255,255,0.28)';
|
||||
badgeColor = '#ffffff';
|
||||
} else {
|
||||
bgStyle = 'rgba(34, 197, 94, 0.16)';
|
||||
borderStyle = '1px solid rgba(34, 197, 94, 0.5)';
|
||||
textColor = '#15803d';
|
||||
badgeBg = 'rgba(34, 197, 94, 0.25)';
|
||||
badgeColor = '#15803d';
|
||||
}
|
||||
} else {
|
||||
if (isSelected) {
|
||||
bgStyle = '#b91c1c';
|
||||
borderStyle = '2px solid #991b1b';
|
||||
textColor = '#ffffff';
|
||||
badgeBg = 'rgba(255,255,255,0.28)';
|
||||
badgeColor = '#ffffff';
|
||||
} else {
|
||||
bgStyle = 'rgba(239, 68, 68, 0.14)';
|
||||
borderStyle = '1px solid rgba(239, 68, 68, 0.4)';
|
||||
textColor = '#b91c1c';
|
||||
badgeBg = 'rgba(239, 68, 68, 0.22)';
|
||||
badgeColor = '#b91c1c';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const weekdays = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
|
||||
const weekdayName = weekdays[dayOfWeek];
|
||||
|
||||
cells.push(`
|
||||
<button type="button" class="ta-cal-day-cell ta-day-pill ${isSelected ? 'active' : ''}" data-date="${isoStr}"
|
||||
style="display:flex; flex-direction:column; justify-content:space-between; align-items:flex-start; padding:8px 10px; min-height:85px; border-radius:var(--radius-md); border:${borderStyle}; background:${bgStyle}; color:${textColor}; cursor:pointer; transition:all 0.15s ease; width:100%; font-family:inherit; text-align:left;">
|
||||
|
||||
<div style="display:flex; justify-content:space-between; width:100%; align-items:center;">
|
||||
<div style="display:flex; align-items:baseline; gap:4px;">
|
||||
<span style="font-size:1.1rem; font-weight:800; color:inherit;">${dayNum}</span>
|
||||
<span style="font-size:0.7rem; font-weight:600; text-transform:uppercase; opacity:0.8; color:inherit;">${weekdayName}</span>
|
||||
</div>
|
||||
${isToday ? `<span style="font-size:0.62rem; font-weight:700; background:var(--accent-primary); color:#fff; padding:1px 5px; border-radius:4px;">OGGI</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:auto; width:100%; display:flex; flex-direction:column; gap:2px;">
|
||||
<span class="badge" style="font-size:0.75rem; font-weight:800; padding:3px 6px; border-radius:6px; background:${badgeBg}; color:${badgeColor}; display:block; text-align:center; width:100%;">
|
||||
⏱️ ${formattedTime}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
|
||||
const monthTitle = `${monthNames[mMonth]} ${mYear}`;
|
||||
return `
|
||||
<div class="ta-month-section" style="margin-bottom:var(--space-xl);">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-md); margin-bottom:var(--space-md);">
|
||||
<h3 style="margin:0; font-size:1.15rem; font-weight:800; color:var(--text-primary); text-transform:capitalize; white-space:nowrap;">
|
||||
📅 ${monthTitle}
|
||||
</h3>
|
||||
<div style="flex:1; height:2px; background:linear-gradient(to right, var(--border-subtle), transparent);"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:8px; margin-bottom:8px; text-align:center; font-weight:700; font-size:0.8rem; color:var(--text-muted);">
|
||||
${headerDays.map(h => `<div>${h}</div>`).join('')}
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns: repeat(7, 1fr); gap:8px;">
|
||||
${cells.join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const selectedParts = this.selectedDate.split('-').map(Number);
|
||||
const selectedYear = selectedParts[0];
|
||||
const selectedMonth = selectedParts[1] - 1;
|
||||
const currentMonthName = `${monthNames[selectedMonth]} ${selectedYear}`;
|
||||
|
||||
return `
|
||||
<div style="padding:var(--space-md) var(--space-xl);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-md); background:var(--bg-secondary); padding:var(--space-sm) var(--space-md); border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-xs);">
|
||||
<button id="ta-cal-toggle-all-time" class="btn btn-ghost btn-sm" style="font-size:0.82rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:${this.showAllTime ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; color:${this.showAllTime ? '#ffffff' : 'var(--text-secondary)'}; border:1px solid var(--border-subtle);" title="Carica l'intero storico delle attività fin dalla prima consuntivazione registrata">
|
||||
🚀 ${this.showAllTime ? 'Tutto lo Storico (Attivo)' : 'Carica Tutto lo Storico'}
|
||||
</button>
|
||||
<button id="ta-cal-show-chart" class="btn btn-ghost btn-sm" style="font-size:0.82rem; font-weight:600; display:inline-flex; align-items:center; gap:6px; background:var(--bg-tertiary); color:var(--text-primary); border:1px solid var(--border-subtle);" title="Apri grafico con l'andamento della consuntivazione">
|
||||
📊 Mostra Grafico
|
||||
</button>
|
||||
<button id="ta-cal-month-prev" class="btn btn-ghost btn-sm" style="font-size:0.9rem; font-weight:600;" ${this.showAllTime ? 'disabled style="opacity:0.4; cursor:not-allowed;"' : ''}>‹ Mese Precedente</button>
|
||||
</div>
|
||||
<span style="font-weight:700; font-size:1.1rem; color:var(--text-primary);">${this.showAllTime ? 'Tutto lo Storico Consuntivato' : currentMonthName}</span>
|
||||
<button id="ta-cal-month-next" class="btn btn-ghost btn-sm" style="font-size:0.9rem; font-weight:600;" ${this.showAllTime ? 'disabled style="opacity:0.4; cursor:not-allowed;"' : ''}>Mese Successivo ›</button>
|
||||
</div>
|
||||
|
||||
${monthsToRender.map(m => renderSingleMonthGrid(m.year, m.month)).join('')}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
buildArticleCard(a) {
|
||||
const isSelected = String(a.article_id) === String(this.selectedArticleId);
|
||||
const dt = a.create_time ? new Date(a.create_time).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }) : '—';
|
||||
@@ -430,6 +634,34 @@ const TimeAccountingView = {
|
||||
},
|
||||
|
||||
bindEvents() {
|
||||
// Toggle View Mode (List <-> Calendar Grid)
|
||||
document.getElementById('ta-toggle-view-mode')?.addEventListener('click', () => {
|
||||
this.viewMode = this.viewMode === 'list' ? 'calendar' : 'list';
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Toggle Show All Time history
|
||||
document.getElementById('ta-cal-toggle-all-time')?.addEventListener('click', () => {
|
||||
this.showAllTime = !this.showAllTime;
|
||||
Toast.info(this.showAllTime ? 'Caricamento dell\'intero storico in corso...' : 'Filtro intervallo standard ripristinato');
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Calendar month navigation
|
||||
document.getElementById('ta-cal-month-prev')?.addEventListener('click', () => {
|
||||
const parts = this.selectedDate.split('-').map(Number);
|
||||
const d = new Date(parts[0], parts[1] - 2, 1);
|
||||
this.selectedDate = this.formatLocalISO(d);
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
document.getElementById('ta-cal-month-next')?.addEventListener('click', () => {
|
||||
const parts = this.selectedDate.split('-').map(Number);
|
||||
const d = new Date(parts[0], parts[1], 1);
|
||||
this.selectedDate = this.formatLocalISO(d);
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Agent change
|
||||
document.getElementById('ta-agent-select')?.addEventListener('change', (e) => {
|
||||
this.selectedAgentId = e.target.value;
|
||||
@@ -455,10 +687,16 @@ const TimeAccountingView = {
|
||||
this.loadData();
|
||||
});
|
||||
|
||||
// Day pill clicks
|
||||
// Day pill clicks (also handles calendar cell clicks)
|
||||
document.querySelectorAll('.ta-day-pill[data-date]').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
this.selectedDate = pill.dataset.date;
|
||||
const dateClicked = pill.dataset.date;
|
||||
if (this.viewMode === 'calendar') {
|
||||
this.selectedDate = dateClicked;
|
||||
this.viewMode = 'list';
|
||||
} else {
|
||||
this.selectedDate = dateClicked;
|
||||
}
|
||||
this.loadData();
|
||||
});
|
||||
});
|
||||
@@ -491,9 +729,12 @@ const TimeAccountingView = {
|
||||
document.querySelectorAll('.ta-article-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
this.selectedArticleId = card.dataset.articleId;
|
||||
const currentScrollX = window.scrollX;
|
||||
const currentScrollY = window.scrollY;
|
||||
const container = document.getElementById('view-container');
|
||||
container.innerHTML = this.buildHtml(this.summaryData);
|
||||
this.bindEvents();
|
||||
window.scrollTo(currentScrollX, currentScrollY);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -548,5 +789,422 @@ const TimeAccountingView = {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show Chart Modal action
|
||||
document.getElementById('ta-cal-show-chart')?.addEventListener('click', () => {
|
||||
this.openChartModal();
|
||||
});
|
||||
},
|
||||
|
||||
async openChartModal() {
|
||||
const existingModal = document.getElementById('ta-chart-modal');
|
||||
if (existingModal) existingModal.remove();
|
||||
|
||||
const users = App.lookups.users || [];
|
||||
|
||||
// Saved banned agent IDs (semitrasparenti e bloccati)
|
||||
let bannedAgentIds = [];
|
||||
try {
|
||||
const savedBanned = localStorage.getItem('otrs_ta_chart_banned_agents');
|
||||
if (savedBanned) bannedAgentIds = JSON.parse(savedBanned);
|
||||
} catch (_) {}
|
||||
if (!Array.isArray(bannedAgentIds)) bannedAgentIds = [];
|
||||
|
||||
// Saved selected agent IDs or default to current agent
|
||||
let activeAgentIds = [];
|
||||
try {
|
||||
const saved = localStorage.getItem('otrs_ta_chart_agents');
|
||||
if (saved) activeAgentIds = JSON.parse(saved);
|
||||
} catch (_) {}
|
||||
|
||||
if (!Array.isArray(activeAgentIds) || activeAgentIds.length === 0) {
|
||||
activeAgentIds = [String(this.selectedAgentId || App.currentAgentId)];
|
||||
}
|
||||
|
||||
// Filter activeAgentIds to remove any banned agents
|
||||
activeAgentIds = activeAgentIds.filter(id => !bannedAgentIds.includes(id));
|
||||
|
||||
// Saved range or default to 'all'
|
||||
let selectedRange = localStorage.getItem('otrs_ta_chart_range') || 'all';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'ta-chart-modal';
|
||||
modal.className = 'modal';
|
||||
modal.style.cssText = 'display:flex; position:fixed; z-index:10000; inset:0; background:rgba(0,0,0,0.65); align-items:center; justify-content:center; padding:20px; backdrop-filter:blur(4px);';
|
||||
|
||||
const isDarkMode = document.body.classList.contains('theme-dark');
|
||||
const textColor = isDarkMode ? '#cbd5e1' : '#334155';
|
||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)';
|
||||
|
||||
// Paletta di colori vivaci per gli agenti nel grafico
|
||||
const colorPalette = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899',
|
||||
'#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#a855f7'
|
||||
];
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width:1100px; width:94vw; max-height:90vh; background:var(--bg-card); border-radius:var(--radius-lg); border:1px solid var(--border-subtle); padding:var(--space-xl); box-shadow:0 20px 50px rgba(0,0,0,0.5); display:flex; flex-direction:column; gap:var(--space-md); overflow:hidden;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); padding-bottom:var(--space-md); flex-wrap:wrap; gap:12px;">
|
||||
<div style="display:flex; align-items:center; gap:var(--space-sm);">
|
||||
<span style="font-size:1.4rem;">📊</span>
|
||||
<div>
|
||||
<span style="font-weight:700; font-size:1.15rem; color:var(--text-primary); display:block;">Andamento Consuntivazione Tempo</span>
|
||||
<span style="font-size:0.78rem; color:var(--text-muted);">Confronto e analisi temporale delle consuntivazioni agenti</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Select Period Filter -->
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary);">Periodo:</label>
|
||||
<select id="ta-chart-range-select" class="form-select" style="height:34px; font-size:0.82rem; padding:0 28px 0 10px; background:var(--bg-secondary);">
|
||||
<option value="all" ${selectedRange === 'all' ? 'selected' : ''}>🚀 Tutto lo Storico</option>
|
||||
<option value="7" ${selectedRange === '7' ? 'selected' : ''}>Ultimi 7 Giorni</option>
|
||||
<option value="14" ${selectedRange === '14' ? 'selected' : ''}>Ultimi 14 Giorni</option>
|
||||
<option value="30" ${selectedRange === '30' ? 'selected' : ''}>Ultimi 30 Giorni</option>
|
||||
<option value="90" ${selectedRange === '90' ? 'selected' : ''}>Ultimi 3 Mesi (90 gg)</option>
|
||||
<option value="180" ${selectedRange === '180' ? 'selected' : ''}>Ultimi 6 Mesi (180 gg)</option>
|
||||
<option value="365" ${selectedRange === '365' ? 'selected' : ''}>Ultimo Anno (365 gg)</option>
|
||||
</select>
|
||||
<button id="ta-chart-modal-close" class="btn btn-ghost btn-sm" style="font-size:1.2rem; line-height:1; padding:4px 10px; cursor:pointer;">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Body (Chart + Agents Sidebar) -->
|
||||
<div style="display:flex; gap:var(--space-md); flex:1; min-height:0; overflow:hidden;">
|
||||
|
||||
<!-- Main Canvas Area -->
|
||||
<div style="flex:1; position:relative; min-height:420px; background:var(--bg-secondary); border-radius:var(--radius-md); padding:16px; border:1px solid var(--border-subtle); display:flex; flex-direction:column;">
|
||||
<div id="ta-chart-loading" style="display:none; position:absolute; inset:0; background:rgba(0,0,0,0.3); backdrop-filter:blur(2px); z-index:10; align-items:center; justify-content:center; color:#fff; font-weight:600; font-size:0.9rem; border-radius:var(--radius-md);">
|
||||
<div class="spinner" style="margin-right:10px;"></div> Caricamento dati...
|
||||
</div>
|
||||
<div style="flex:1; position:relative; width:100%; height:100%;">
|
||||
<canvas id="ta-chart-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar: Agents Selection Toggle List -->
|
||||
<div style="width:270px; flex-shrink:0; background:var(--bg-secondary); border-radius:var(--radius-md); border:1px solid var(--border-subtle); padding:12px; display:flex; flex-direction:column; gap:10px;">
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-primary); border-bottom:1px solid var(--border-subtle); padding-bottom:6px; display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>👥 Agenti (${users.length})</span>
|
||||
<button id="ta-chart-select-all" class="btn btn-ghost btn-sm" style="font-size:0.7rem; padding:2px 6px;">Tutti</button>
|
||||
</div>
|
||||
<div id="ta-chart-agents-list" style="flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:6px; padding-right:4px;">
|
||||
${users.map((u, idx) => {
|
||||
const uId = String(u.id);
|
||||
const isBanned = bannedAgentIds.includes(uId);
|
||||
const isChecked = !isBanned && activeAgentIds.includes(uId);
|
||||
const color = colorPalette[idx % colorPalette.length];
|
||||
return `
|
||||
<div class="ta-agent-toggle-item ${isBanned ? 'banned' : ''}" data-id="${uId}" style="display:flex; align-items:center; gap:6px; padding:5px 8px; border-radius:6px; background:${isChecked ? 'var(--bg-tertiary)' : 'transparent'}; border:1px solid ${isChecked ? 'var(--border-subtle)' : 'transparent'}; font-size:0.8rem; user-select:none; transition:all 0.15s ease; ${isBanned ? 'opacity:0.4; pointer-events:none; cursor:not-allowed;' : ''}">
|
||||
<input type="checkbox" class="ta-agent-checkbox" value="${uId}" ${isChecked ? 'checked' : ''} ${isBanned ? 'disabled' : ''} style="cursor:pointer;" />
|
||||
<span style="width:10px; height:10px; border-radius:50%; background:${color}; flex-shrink:0;"></span>
|
||||
<span style="font-weight:${isChecked ? '600' : 'normal'}; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;" title="${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}">
|
||||
${App.escapeHtml(u.first_name)} ${App.escapeHtml(u.last_name)}
|
||||
</span>
|
||||
<span class="ta-agent-total-badge" style="font-size:0.72rem; font-weight:700; font-family:monospace; color:var(--text-muted); background:var(--bg-card); border:1px solid var(--border-subtle); padding:1px 5px; border-radius:10px; flex-shrink:0;">
|
||||
0h
|
||||
</span>
|
||||
<button type="button" class="ta-agent-ban-btn" data-id="${uId}" style="pointer-events:auto; background:none; border:none; cursor:pointer; font-size:0.85rem; padding:0 2px; line-height:1; opacity:${isBanned ? '1' : '0.5'}; transition:opacity 0.15s ease;" title="${isBanned ? 'Sblocca agente' : 'Blocca agente (Escludi dal grafico e dal pulsante Tutti)'}">
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div id="ta-chart-footer-info" style="display:flex; justify-content:space-between; align-items:center; font-size:0.8rem; color:var(--text-muted); border-top:1px solid var(--border-subtle); padding-top:var(--space-sm);">
|
||||
<span>Agenti selezionati: <strong id="ta-chart-agents-count">${activeAgentIds.length}</strong></span>
|
||||
<span>Target Giornaliero: <strong>8.0h (480 min)</strong></span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const closeModal = () => {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy();
|
||||
this.chartInstance = null;
|
||||
}
|
||||
modal.remove();
|
||||
};
|
||||
|
||||
modal.querySelector('#ta-chart-modal-close').onclick = closeModal;
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
};
|
||||
|
||||
// Function to fetch data and refresh chart instance
|
||||
const refreshChartData = async () => {
|
||||
const loadingEl = modal.querySelector('#ta-chart-loading');
|
||||
if (loadingEl) loadingEl.style.display = 'flex';
|
||||
|
||||
const checkboxes = modal.querySelectorAll('.ta-agent-checkbox:checked');
|
||||
const selectedIds = Array.from(checkboxes).map(c => c.value);
|
||||
|
||||
// Save state to localStorage
|
||||
localStorage.setItem('otrs_ta_chart_agents', JSON.stringify(selectedIds));
|
||||
localStorage.setItem('otrs_ta_chart_range', selectedRange);
|
||||
|
||||
const countEl = modal.querySelector('#ta-chart-agents-count');
|
||||
if (countEl) countEl.textContent = selectedIds.length;
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy();
|
||||
this.chartInstance = null;
|
||||
}
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
agent_ids: selectedIds.join(','),
|
||||
date: this.selectedDate,
|
||||
range: selectedRange,
|
||||
});
|
||||
|
||||
const data = await App.api(`/api/tickets/time-accounting/summary?${params}`);
|
||||
const dailyTotalsByAgent = data.daily_totals_by_agent || {};
|
||||
const targetMins = data.target_minutes || 480;
|
||||
const targetHoursVal = +(targetMins / 60).toFixed(2);
|
||||
|
||||
// Compute set of all dates across selected agents
|
||||
let allDates = [];
|
||||
Object.values(dailyTotalsByAgent).forEach(agentTotals => {
|
||||
Object.keys(agentTotals).forEach(d => {
|
||||
if (!allDates.includes(d)) allDates.push(d);
|
||||
});
|
||||
});
|
||||
allDates.sort();
|
||||
|
||||
// If numeric range (e.g. last 30, 90, 180, 365 days), slice dates
|
||||
if (selectedRange !== 'all' && !isNaN(parseInt(selectedRange, 10))) {
|
||||
const limitDays = parseInt(selectedRange, 10);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - limitDays);
|
||||
const cutoffIso = this.formatLocalISO(cutoff);
|
||||
allDates = allDates.filter(d => d >= cutoffIso);
|
||||
}
|
||||
|
||||
const labels = allDates.map(d => {
|
||||
const parts = d.split('-');
|
||||
return `${parts[2]}/${parts[1]}`;
|
||||
});
|
||||
|
||||
// Update total hours badges for all agents in the sidebar list
|
||||
users.forEach((u) => {
|
||||
const uId = String(u.id);
|
||||
const agentTotals = dailyTotalsByAgent[uId] || {};
|
||||
let totalMinsForAgent = 0;
|
||||
allDates.forEach(d => {
|
||||
totalMinsForAgent += (agentTotals[d] || 0);
|
||||
});
|
||||
|
||||
const totalHoursVal = (totalMinsForAgent / 60).toFixed(1);
|
||||
const badgeEl = modal.querySelector(`.ta-agent-toggle-item[data-id="${uId}"] .ta-agent-total-badge`);
|
||||
if (badgeEl) {
|
||||
badgeEl.textContent = `${totalHoursVal}h`;
|
||||
if (totalMinsForAgent > 0) {
|
||||
badgeEl.style.color = 'var(--accent-primary)';
|
||||
badgeEl.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
||||
} else {
|
||||
badgeEl.style.color = 'var(--text-muted)';
|
||||
badgeEl.style.borderColor = 'var(--border-subtle)';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build dataset per agent
|
||||
const datasets = selectedIds.map((agId) => {
|
||||
const userObj = users.find(u => String(u.id) === String(agId));
|
||||
const idx = users.findIndex(u => String(u.id) === String(agId));
|
||||
const color = colorPalette[idx >= 0 ? idx % colorPalette.length : 0];
|
||||
const agentTotals = dailyTotalsByAgent[agId] || {};
|
||||
|
||||
const hoursData = allDates.map(d => {
|
||||
const mins = agentTotals[d] || 0;
|
||||
return +(mins / 60).toFixed(2);
|
||||
});
|
||||
|
||||
return {
|
||||
label: userObj ? `${userObj.first_name} ${userObj.last_name}` : `Agente #${agId}`,
|
||||
data: hoursData,
|
||||
borderColor: color,
|
||||
backgroundColor: color + '22',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.25,
|
||||
pointBackgroundColor: color,
|
||||
pointRadius: 1.5,
|
||||
pointHoverRadius: 5,
|
||||
};
|
||||
});
|
||||
|
||||
// Add Target reference line
|
||||
datasets.push({
|
||||
label: 'Soglia Target (Ore)',
|
||||
data: allDates.map(() => targetHoursVal),
|
||||
borderColor: '#ef4444',
|
||||
borderDash: [5, 5],
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
});
|
||||
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy();
|
||||
}
|
||||
|
||||
if (window.Chart) {
|
||||
const ctx = modal.querySelector('#ta-chart-canvas').getContext('2d');
|
||||
this.chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const val = context.raw || 0;
|
||||
const mins = Math.round(val * 60);
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
const timeFormatted = h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
return `${context.dataset.label}: ${timeFormatted} (${val}h)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor, font: { family: 'Inter', size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { family: 'Inter', size: 10 },
|
||||
callback: function(value) {
|
||||
return value + 'h';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading chart data:', err);
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Range selector change event
|
||||
const rangeSelect = modal.querySelector('#ta-chart-range-select');
|
||||
if (rangeSelect) {
|
||||
rangeSelect.onchange = (e) => {
|
||||
selectedRange = e.target.value;
|
||||
refreshChartData();
|
||||
};
|
||||
}
|
||||
|
||||
// Agent checkboxes toggle events
|
||||
modal.querySelectorAll('.ta-agent-checkbox').forEach(chk => {
|
||||
chk.onchange = (e) => {
|
||||
const itemLabel = e.target.closest('.ta-agent-toggle-item');
|
||||
if (itemLabel) {
|
||||
itemLabel.style.background = e.target.checked ? 'var(--bg-tertiary)' : 'transparent';
|
||||
itemLabel.style.borderColor = e.target.checked ? 'var(--border-subtle)' : 'transparent';
|
||||
}
|
||||
refreshChartData();
|
||||
};
|
||||
});
|
||||
|
||||
// Agent Ban/Unban toggle button events 🚫
|
||||
modal.querySelectorAll('.ta-agent-ban-btn').forEach(btn => {
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const uId = String(btn.dataset.id);
|
||||
const itemLabel = btn.closest('.ta-agent-toggle-item');
|
||||
const chk = itemLabel ? itemLabel.querySelector('.ta-agent-checkbox') : null;
|
||||
|
||||
const isCurrentlyBanned = bannedAgentIds.includes(uId);
|
||||
if (isCurrentlyBanned) {
|
||||
bannedAgentIds = bannedAgentIds.filter(id => id !== uId);
|
||||
if (itemLabel) {
|
||||
itemLabel.classList.remove('banned');
|
||||
itemLabel.style.opacity = '1';
|
||||
itemLabel.style.pointerEvents = 'auto';
|
||||
}
|
||||
if (chk) {
|
||||
chk.disabled = false;
|
||||
}
|
||||
btn.title = 'Blocca agente (Escludi dal grafico e dal pulsante Tutti)';
|
||||
btn.style.opacity = '0.5';
|
||||
} else {
|
||||
bannedAgentIds.push(uId);
|
||||
if (itemLabel) {
|
||||
itemLabel.classList.add('banned');
|
||||
itemLabel.style.opacity = '0.4';
|
||||
itemLabel.style.pointerEvents = 'none';
|
||||
itemLabel.style.background = 'transparent';
|
||||
itemLabel.style.borderColor = 'transparent';
|
||||
}
|
||||
if (chk) {
|
||||
chk.checked = false;
|
||||
chk.disabled = true;
|
||||
}
|
||||
btn.title = 'Sblocca agente';
|
||||
btn.style.opacity = '1';
|
||||
}
|
||||
|
||||
localStorage.setItem('otrs_ta_chart_banned_agents', JSON.stringify(bannedAgentIds));
|
||||
refreshChartData();
|
||||
};
|
||||
});
|
||||
|
||||
// Select All agents button (ignores banned agents)
|
||||
const selectAllBtn = modal.querySelector('#ta-chart-select-all');
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.onclick = () => {
|
||||
// Only select/unselect checkboxes that are not disabled/banned
|
||||
const activeCheckboxes = modal.querySelectorAll('.ta-agent-checkbox:not(:disabled)');
|
||||
const allChecked = Array.from(activeCheckboxes).every(c => c.checked);
|
||||
activeCheckboxes.forEach(chk => {
|
||||
chk.checked = !allChecked;
|
||||
const itemLabel = chk.closest('.ta-agent-toggle-item');
|
||||
if (itemLabel) {
|
||||
itemLabel.style.background = chk.checked ? 'var(--bg-tertiary)' : 'transparent';
|
||||
itemLabel.style.borderColor = chk.checked ? 'var(--border-subtle)' : 'transparent';
|
||||
}
|
||||
});
|
||||
refreshChartData();
|
||||
};
|
||||
}
|
||||
|
||||
// Initial render
|
||||
refreshChartData();
|
||||
}
|
||||
};
|
||||
@@ -30,6 +30,11 @@ const UserConfigView = {
|
||||
},
|
||||
|
||||
renderUI(container, signatures = [], shapes = []) {
|
||||
const activeAgentId = App.currentAgentId;
|
||||
const activeUser = (App.lookups.users || []).find(u => String(u.id) === String(activeAgentId));
|
||||
const agentName = activeUser ? `${activeUser.first_name} ${activeUser.last_name}`.trim() : '';
|
||||
const placeholderText = agentName ? `Es. Firma ${agentName}, Timbro Aziendale...` : 'Es. Firma Utente, Timbro Aziendale...';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="user-config-page">
|
||||
<!-- Section 1: Custom Shapes -->
|
||||
@@ -43,46 +48,42 @@ const UserConfigView = {
|
||||
<h4 style="margin-top:0; margin-bottom:12px; font-size:0.95rem; color:var(--text-primary);">Crea Nuova Forma Personalizzata</h4>
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap:12px; align-items:flex-end;">
|
||||
<div>
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Nome Forma</label>
|
||||
<input type="text" id="shape-name" class="form-input" placeholder="Es. Evidenziatore Blu, Timbro Approvato..." style="height:34px; font-size:0.85rem;" />
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Nome Identificativo</label>
|
||||
<input type="text" id="user-shape-name" class="form-input" placeholder="Es. Rettangolo Blu Evidenziatore" style="height:34px; font-size:0.85rem;" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Tipo Forma</label>
|
||||
<select id="shape-type" class="form-select" style="height:34px; font-size:0.85rem;">
|
||||
<option value="rectangle">🟦 Rettangolo</option>
|
||||
<option value="circle">⚪ Cerchio / Ellisse</option>
|
||||
<select id="user-shape-type" class="form-select" style="height:34px; font-size:0.85rem;">
|
||||
<option value="rect">Rettangolo</option>
|
||||
<option value="circle">Cerchio / Ellisse</option>
|
||||
<option value="line">Linea Rettilinea</option>
|
||||
<option value="arrow">Freccia di Indicazione</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Colore Riempimento</label>
|
||||
<div style="display:flex; align-items:center; gap:6px;">
|
||||
<input type="color" id="shape-fill-color" value="#3b82f6" style="width:34px; height:34px; border:none; border-radius:4px; cursor:pointer; background:none;" />
|
||||
<label style="font-size:0.78rem; color:var(--text-muted); cursor:pointer;"><input type="checkbox" id="shape-transparent-fill" /> Trasparente</label>
|
||||
</div>
|
||||
<input type="color" id="user-shape-fill" value="#3b82f6" style="width:100%; height:34px; border:1px solid var(--border-subtle); border-radius:4px; cursor:pointer; background:none;" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Colore Bordo</label>
|
||||
<input type="color" id="shape-border-color" value="#1e3a8a" style="width:34px; height:34px; border:none; border-radius:4px; cursor:pointer; background:none;" />
|
||||
<input type="color" id="user-shape-border" value="#1e3a8a" style="width:100%; height:34px; border:1px solid var(--border-subtle); border-radius:4px; cursor:pointer; background:none;" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Spessore Bordo (px)</label>
|
||||
<input type="number" id="shape-border-width" min="0" max="30" value="3" class="form-input" style="height:34px; font-size:0.85rem;" />
|
||||
<input type="number" id="user-shape-width" min="0" max="30" value="3" class="form-input" style="height:34px; font-size:0.85rem;" />
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="btn-save-shape" class="btn btn-primary btn-sm" style="height:34px; padding:0 16px; font-weight:600; display:inline-flex; align-items:center; gap:6px;">
|
||||
⭐ Salva Forma
|
||||
</button>
|
||||
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
||||
<input type="checkbox" id="user-shape-fav" style="cursor:pointer;" />
|
||||
<label for="user-shape-fav" style="font-size:0.8rem; cursor:pointer; color:var(--text-primary); font-weight:600;">⭐ Preferito</label>
|
||||
</div>
|
||||
<button id="btn-save-user-shape" class="btn btn-primary btn-sm" style="height:34px; padding:0 16px; font-weight:600; display:inline-flex; align-items:center; gap:6px;">
|
||||
➕ Salva Forma
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shapes Gallery -->
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap:14px;">
|
||||
<!-- Shapes Gallery Grid -->
|
||||
<div id="user-shapes-list" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap:12px;">
|
||||
${shapes.length > 0 ? shapes.map(sh => `
|
||||
<div class="user-shape-card" style="background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:12px; display:flex; flex-direction:column; position:relative; box-shadow:var(--shadow-sm);">
|
||||
<div style="position:absolute; top:8px; right:8px; display:flex; gap:4px;">
|
||||
@@ -90,15 +91,36 @@ const UserConfigView = {
|
||||
<button class="btn-delete-shape" data-id="${sh.id}" style="background:var(--bg-tertiary); border:1px solid var(--border-subtle); color:var(--danger); border-radius:50%; width:24px; height:24px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:11px;" title="Elimina Forma">✕</button>
|
||||
</div>
|
||||
|
||||
<div style="width:100%; height:70px; display:flex; align-items:center; justify-content:center; background:var(--bg-tertiary); border-radius:var(--radius-sm); margin-bottom:8px; border:1px dashed var(--border-subtle);">
|
||||
<div style="width:50px; height:34px; border-radius:${sh.shape_type === 'circle' ? '50%' : '3px'}; background:${sh.fill_color === 'transparent' ? 'transparent' : sh.fill_color}; border:${sh.border_width}px solid ${sh.border_color}; box-sizing:border-box;"></div>
|
||||
<div style="width:100%; height:70px; display:flex; align-items:center; justify-content:center; background:var(--bg-tertiary); border-radius:var(--radius-sm); margin-bottom:8px; border:1px dashed var(--border-subtle); padding:4px;">
|
||||
${(() => {
|
||||
const st = sh.shape_type;
|
||||
const fill = sh.fill_color === 'transparent' ? 'none' : sh.fill_color;
|
||||
const stroke = sh.border_color || '#3b82f6';
|
||||
const sw = sh.border_width || 3;
|
||||
if (st === 'circle') {
|
||||
return `<svg width="60" height="50" viewBox="0 0 60 50"><ellipse cx="30" cy="25" rx="22" ry="18" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" /></svg>`;
|
||||
} else if (st === 'line') {
|
||||
return `<svg width="60" height="50" viewBox="0 0 60 50"><line x1="8" y1="42" x2="52" y2="8" stroke="${stroke}" stroke-width="${Math.max(sw, 2)}" stroke-linecap="round" /></svg>`;
|
||||
} else if (st === 'arrow') {
|
||||
return `<svg width="60" height="50" viewBox="0 0 60 50">
|
||||
<defs>
|
||||
<marker id="arr-${sh.id}" viewBox="0 0 10 10" refX="6" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="${stroke}" />
|
||||
</marker>
|
||||
</defs>
|
||||
<line x1="8" y1="40" x2="48" y2="12" stroke="${stroke}" stroke-width="${Math.max(sw, 2)}" stroke-linecap="round" marker-end="url(#arr-${sh.id})" />
|
||||
</svg>`;
|
||||
} else {
|
||||
return `<svg width="60" height="50" viewBox="0 0 60 50"><rect x="8" y="10" width="44" height="30" rx="3" fill="${fill}" stroke="${stroke}" stroke-width="${sw}" /></svg>`;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style="font-weight:600; font-size:0.85rem; color:var(--text-primary); margin-bottom:4px;">
|
||||
${App.escapeHtml(sh.name)}
|
||||
</div>
|
||||
<div style="font-size:0.74rem; color:var(--text-muted);">
|
||||
${sh.shape_type === 'circle' ? 'Cerchio' : 'Rettangolo'} • Bordo ${sh.border_width}px
|
||||
${sh.shape_type === 'circle' ? 'Cerchio' : sh.shape_type === 'line' ? 'Linea' : sh.shape_type === 'arrow' ? 'Freccia' : 'Rettangolo'} • Bordo ${sh.border_width}px
|
||||
</div>
|
||||
</div>
|
||||
`).join('') : `
|
||||
@@ -119,14 +141,18 @@ const UserConfigView = {
|
||||
<div style="background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; margin-bottom: 20px;">
|
||||
<h4 style="margin-top:0; margin-bottom:12px; font-size:0.95rem; color:var(--text-primary);">Aggiungi nuova immagine / firma</h4>
|
||||
<div style="display:flex; gap:12px; align-items:flex-end; flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:220px;">
|
||||
<div style="flex:1; min-width:200px;">
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Nome Firma / Immagine</label>
|
||||
<input type="text" id="user-sig-name" class="form-input" placeholder="Es. Firma Gabriele Cimaschi, Timbro Aziendale..." style="height:34px; font-size:0.85rem;" />
|
||||
<input type="text" id="user-sig-name" class="form-input" placeholder="${App.escapeHtml(placeholderText)}" style="height:34px; font-size:0.85rem;" />
|
||||
</div>
|
||||
<div style="flex:1; min-width:220px;">
|
||||
<div style="flex:1; min-width:200px;">
|
||||
<label style="font-size:0.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:4px; display:block;">Seleziona File Immagine (PNG, JPG, SVG)</label>
|
||||
<input type="file" id="user-sig-file" accept="image/*" class="form-input" style="height:34px; padding:3px 8px; font-size:0.8rem;" />
|
||||
</div>
|
||||
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
|
||||
<input type="checkbox" id="user-sig-default" style="cursor:pointer;" />
|
||||
<label for="user-sig-default" style="font-size:0.8rem; cursor:pointer; color:var(--text-primary); font-weight:600;">⭐ Firma Predefinita</label>
|
||||
</div>
|
||||
<button id="btn-save-user-sig" class="btn btn-primary btn-sm" style="height:34px; padding:0 16px; font-weight:600; display:inline-flex; align-items:center; gap:6px;">
|
||||
➕ Salva in SQLite
|
||||
</button>
|
||||
@@ -136,9 +162,14 @@ const UserConfigView = {
|
||||
<!-- Signatures Gallery Grid -->
|
||||
<div id="user-sig-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:16px;">
|
||||
${signatures.length > 0 ? signatures.map(sig => `
|
||||
<div class="user-sig-card" style="background:var(--bg-card); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:12px; display:flex; flex-direction:column; align-items:center; position:relative; box-shadow:var(--shadow-sm);">
|
||||
<div class="user-sig-card" style="background:var(--bg-card); border:${sig.is_default ? '2px solid var(--accent-primary)' : '1px solid var(--border-subtle)'}; border-radius:var(--radius-md); padding:12px; display:flex; flex-direction:column; align-items:center; position:relative; box-shadow:var(--shadow-sm);">
|
||||
<div style="position:absolute; top:8px; left:8px; display:flex; gap:4px; align-items:center;">
|
||||
<button class="btn-default-sig" data-id="${sig.id}" style="background:${sig.is_default ? 'var(--accent-primary)' : 'var(--bg-tertiary)'}; border:1px solid var(--border-subtle); color:${sig.is_default ? '#fff' : 'var(--text-secondary)'}; border-radius:12px; padding:2px 8px; cursor:pointer; font-size:0.7rem; font-weight:600;" title="${sig.is_default ? 'Firma Predefinita attuale' : 'Imposta come predefinita'}">
|
||||
${sig.is_default ? '⭐ Predefinita' : '☆ Imposta Predefinita'}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-delete-sig" data-id="${sig.id}" style="position:absolute; top:8px; right:8px; background:var(--bg-tertiary); border:1px solid var(--border-subtle); color:var(--danger); border-radius:50%; width:26px; height:26px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:12px;" title="Elimina Firma">✕</button>
|
||||
<div style="width:100%; height:110px; display:flex; align-items:center; justify-content:center; background:#ffffff; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); padding:8px; margin-bottom:8px;">
|
||||
<div style="width:100%; height:110px; display:flex; align-items:center; justify-content:center; background:#ffffff; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); padding:8px; margin-bottom:8px; margin-top:24px;">
|
||||
<img src="${sig.image_data}" alt="${App.escapeHtml(sig.name)}" style="max-width:100%; max-height:100%; object-fit:contain;" />
|
||||
</div>
|
||||
<div style="font-weight:600; font-size:0.85rem; color:var(--text-primary); text-align:center; word-break:break-word;">
|
||||
@@ -163,15 +194,15 @@ const UserConfigView = {
|
||||
|
||||
bindEvents(container) {
|
||||
// Shapes handlers
|
||||
const btnSaveShape = container.querySelector('#btn-save-shape');
|
||||
const btnSaveShape = container.querySelector('#btn-save-user-shape');
|
||||
if (btnSaveShape) {
|
||||
btnSaveShape.addEventListener('click', async () => {
|
||||
const name = container.querySelector('#shape-name').value.trim();
|
||||
const shapeType = container.querySelector('#shape-type').value;
|
||||
const isTransparent = container.querySelector('#shape-transparent-fill').checked;
|
||||
const fillColor = isTransparent ? 'transparent' : container.querySelector('#shape-fill-color').value;
|
||||
const borderColor = container.querySelector('#shape-border-color').value;
|
||||
const borderWidth = parseInt(container.querySelector('#shape-border-width').value || 3, 10);
|
||||
const name = container.querySelector('#user-shape-name').value.trim();
|
||||
const shapeType = container.querySelector('#user-shape-type').value;
|
||||
const fillColor = container.querySelector('#user-shape-fill').value;
|
||||
const borderColor = container.querySelector('#user-shape-border').value;
|
||||
const borderWidth = parseInt(container.querySelector('#user-shape-width').value || 3, 10);
|
||||
const isFavorite = container.querySelector('#user-shape-fav').checked;
|
||||
|
||||
if (!name) {
|
||||
Toast.warning('Inserisci un nome per la forma.');
|
||||
@@ -188,7 +219,7 @@ const UserConfigView = {
|
||||
fill_color: fillColor,
|
||||
border_color: borderColor,
|
||||
border_width: borderWidth,
|
||||
is_favorite: 1
|
||||
is_favorite: isFavorite ? 1 : 0
|
||||
})
|
||||
});
|
||||
Toast.success('Forma salvata!');
|
||||
@@ -232,11 +263,13 @@ const UserConfigView = {
|
||||
const btnSave = container.querySelector('#btn-save-user-sig');
|
||||
const inputName = container.querySelector('#user-sig-name');
|
||||
const inputFile = container.querySelector('#user-sig-file');
|
||||
const checkDefault = container.querySelector('#user-sig-default');
|
||||
|
||||
if (btnSave && inputFile) {
|
||||
btnSave.addEventListener('click', async () => {
|
||||
const name = inputName.value.trim();
|
||||
const file = inputFile.files[0];
|
||||
const isDefault = checkDefault ? checkDefault.checked : false;
|
||||
|
||||
if (!name) {
|
||||
Toast.warning('Inserisci un nome descrittivo per la firma/immagine.');
|
||||
@@ -258,7 +291,8 @@ const UserConfigView = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
image_data: base64Data
|
||||
image_data: base64Data,
|
||||
is_default: isDefault ? 1 : 0
|
||||
})
|
||||
});
|
||||
|
||||
@@ -274,6 +308,19 @@ const UserConfigView = {
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('.btn-default-sig').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await App.api(`/api/config/user-signatures/${btn.dataset.id}/set-default`, { method: 'PUT' });
|
||||
Toast.success('Firma predefinita aggiornata!');
|
||||
this.render();
|
||||
} catch (err) {
|
||||
Toast.error('Errore aggiornamento firma predefinita: ' + err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.btn-delete-sig').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
+36
-7
@@ -192,7 +192,7 @@ router.get('/user-signatures', (req, res) => {
|
||||
try {
|
||||
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||
const signatures = db.prepare(`
|
||||
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY id DESC
|
||||
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||
`).all(operatorId);
|
||||
res.json(signatures);
|
||||
} catch (err) {
|
||||
@@ -205,19 +205,24 @@ router.get('/user-signatures', (req, res) => {
|
||||
router.post('/user-signatures', (req, res) => {
|
||||
try {
|
||||
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||
const { name, image_data } = req.body;
|
||||
const { name, image_data, is_default } = req.body;
|
||||
|
||||
if (!name || !image_data) {
|
||||
return res.status(400).json({ error: 'Nome e dati immagine sono obbligatori' });
|
||||
}
|
||||
|
||||
const defaultVal = is_default ? 1 : 0;
|
||||
if (defaultVal === 1) {
|
||||
db.prepare(`UPDATE user_signatures SET is_default = 0 WHERE agent_id = ?`).run(operatorId);
|
||||
}
|
||||
|
||||
const info = db.prepare(`
|
||||
INSERT INTO user_signatures (agent_id, name, image_data)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(operatorId, name.trim(), image_data);
|
||||
INSERT INTO user_signatures (agent_id, name, image_data, is_default)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(operatorId, name.trim(), image_data, defaultVal);
|
||||
|
||||
const signatures = db.prepare(`
|
||||
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY id DESC
|
||||
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||
`).all(operatorId);
|
||||
|
||||
res.json({
|
||||
@@ -232,6 +237,30 @@ router.post('/user-signatures', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/config/user-signatures/:id/set-default — Set signature as default
|
||||
router.put('/user-signatures/:id/set-default', (req, res) => {
|
||||
try {
|
||||
const operatorId = parseInt(req.headers['x-agent-id'] || '1', 10);
|
||||
const { id } = req.params;
|
||||
|
||||
db.prepare(`UPDATE user_signatures SET is_default = 0 WHERE agent_id = ?`).run(operatorId);
|
||||
db.prepare(`UPDATE user_signatures SET is_default = 1 WHERE id = ? AND agent_id = ?`).run(id, operatorId);
|
||||
|
||||
const signatures = db.prepare(`
|
||||
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||
`).all(operatorId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Firma predefinita aggiornata con successo!',
|
||||
signatures
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error setting default signature:', err);
|
||||
res.status(500).json({ error: 'Errore aggiornamento firma predefinita', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/config/user-signatures/:id — Delete user signature image
|
||||
router.delete('/user-signatures/:id', (req, res) => {
|
||||
try {
|
||||
@@ -241,7 +270,7 @@ router.delete('/user-signatures/:id', (req, res) => {
|
||||
db.prepare(`DELETE FROM user_signatures WHERE id = ? AND agent_id = ?`).run(id, operatorId);
|
||||
|
||||
const signatures = db.prepare(`
|
||||
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY id DESC
|
||||
SELECT * FROM user_signatures WHERE agent_id = ? ORDER BY is_default DESC, id DESC
|
||||
`).all(operatorId);
|
||||
|
||||
res.json({
|
||||
|
||||
+95
-39
@@ -2151,50 +2151,98 @@ router.post('/merge', async (req, res) => {
|
||||
router.get('/time-accounting/summary', async (req, res) => {
|
||||
try {
|
||||
const operatorId = parseInt(req.headers['x-agent-id'], 10) || 1;
|
||||
const agentId = parseInt(req.query.agent_id, 10) || operatorId;
|
||||
const agentIdsRaw = req.query.agent_ids || req.query.agent_id || String(operatorId);
|
||||
const agentIds = agentIdsRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x));
|
||||
const dateStr = req.query.date || new Date().toISOString().slice(0, 10);
|
||||
|
||||
const targetDate = new Date(`${dateStr}T00:00:00Z`);
|
||||
const startDateObj = new Date(targetDate);
|
||||
startDateObj.setDate(startDateObj.getDate() - 14);
|
||||
const endDateObj = new Date(targetDate);
|
||||
endDateObj.setDate(endDateObj.getDate() + 14);
|
||||
const rangeParam = req.query.range || '35';
|
||||
const isAll = rangeParam === 'all';
|
||||
let result;
|
||||
if (isAll) {
|
||||
result = await pool.query(
|
||||
`SELECT
|
||||
a.id AS article_id,
|
||||
a.ticket_id,
|
||||
a.create_by AS agent_id,
|
||||
t.tn AS ticket_tn,
|
||||
t.title AS ticket_title,
|
||||
ast.name AS sender_type,
|
||||
cc.name AS channel,
|
||||
adm.a_subject,
|
||||
adm.a_body,
|
||||
adm.a_from,
|
||||
adm.a_to,
|
||||
a.create_time,
|
||||
COALESCE(ta.time_unit, 0) AS time_unit
|
||||
FROM article a
|
||||
JOIN ticket t ON a.ticket_id = t.id
|
||||
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
|
||||
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||
LEFT JOIN (
|
||||
SELECT article_id, SUM(time_unit) AS time_unit
|
||||
FROM time_accounting
|
||||
GROUP BY article_id
|
||||
) ta ON a.id = ta.article_id
|
||||
WHERE a.create_by = ANY($1::int[])
|
||||
ORDER BY a.create_time DESC, a.id DESC`,
|
||||
[agentIds]
|
||||
);
|
||||
} else {
|
||||
const targetDate = new Date(`${dateStr}T00:00:00Z`);
|
||||
let startDateObj, endDateObj;
|
||||
|
||||
const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`;
|
||||
const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`;
|
||||
if (rangeParam === 'month') {
|
||||
const y = targetDate.getUTCFullYear();
|
||||
const m = targetDate.getUTCMonth();
|
||||
startDateObj = new Date(Date.UTC(y, m, 1));
|
||||
endDateObj = new Date(Date.UTC(y, m + 1, 0));
|
||||
} else {
|
||||
const daysBack = parseInt(rangeParam, 10) || 35;
|
||||
startDateObj = new Date(targetDate);
|
||||
startDateObj.setDate(startDateObj.getDate() - daysBack);
|
||||
endDateObj = new Date(targetDate);
|
||||
endDateObj.setDate(endDateObj.getDate() + 5);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
a.id AS article_id,
|
||||
a.ticket_id,
|
||||
t.tn AS ticket_tn,
|
||||
t.title AS ticket_title,
|
||||
ast.name AS sender_type,
|
||||
cc.name AS channel,
|
||||
adm.a_subject,
|
||||
adm.a_body,
|
||||
adm.a_from,
|
||||
adm.a_to,
|
||||
a.create_time,
|
||||
COALESCE(ta.time_unit, 0) AS time_unit
|
||||
FROM article a
|
||||
JOIN ticket t ON a.ticket_id = t.id
|
||||
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
|
||||
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||
LEFT JOIN (
|
||||
SELECT article_id, SUM(time_unit) AS time_unit
|
||||
FROM time_accounting
|
||||
GROUP BY article_id
|
||||
) ta ON a.id = ta.article_id
|
||||
WHERE a.create_by = $1
|
||||
AND a.create_time >= $2
|
||||
AND a.create_time <= $3
|
||||
ORDER BY a.create_time DESC, a.id DESC`,
|
||||
[agentId, startTime, endTime]
|
||||
);
|
||||
const startTime = `${startDateObj.toISOString().slice(0, 10)} 00:00:00`;
|
||||
const endTime = `${endDateObj.toISOString().slice(0, 10)} 23:59:59`;
|
||||
|
||||
result = await pool.query(
|
||||
`SELECT
|
||||
a.id AS article_id,
|
||||
a.ticket_id,
|
||||
a.create_by AS agent_id,
|
||||
t.tn AS ticket_tn,
|
||||
t.title AS ticket_title,
|
||||
ast.name AS sender_type,
|
||||
cc.name AS channel,
|
||||
adm.a_subject,
|
||||
adm.a_body,
|
||||
adm.a_from,
|
||||
adm.a_to,
|
||||
a.create_time,
|
||||
COALESCE(ta.time_unit, 0) AS time_unit
|
||||
FROM article a
|
||||
JOIN ticket t ON a.ticket_id = t.id
|
||||
JOIN article_sender_type ast ON a.article_sender_type_id = ast.id
|
||||
LEFT JOIN communication_channel cc ON a.communication_channel_id = cc.id
|
||||
LEFT JOIN article_data_mime adm ON a.id = adm.article_id
|
||||
LEFT JOIN (
|
||||
SELECT article_id, SUM(time_unit) AS time_unit
|
||||
FROM time_accounting
|
||||
GROUP BY article_id
|
||||
) ta ON a.id = ta.article_id
|
||||
WHERE a.create_by = ANY($1::int[])
|
||||
AND a.create_time >= $2
|
||||
AND a.create_time <= $3
|
||||
ORDER BY a.create_time DESC, a.id DESC`,
|
||||
[agentIds, startTime, endTime]
|
||||
);
|
||||
}
|
||||
|
||||
const dailyTotals = {};
|
||||
const dailyTotalsByAgent = {};
|
||||
const dayArticles = [];
|
||||
let totalMinutes = 0;
|
||||
|
||||
@@ -2209,9 +2257,15 @@ router.get('/time-accounting/summary', async (req, res) => {
|
||||
isoDate = String(r.create_time || '').slice(0, 10);
|
||||
}
|
||||
const timeVal = parseFloat(r.time_unit || 0);
|
||||
const agId = r.agent_id;
|
||||
|
||||
dailyTotals[isoDate] = (dailyTotals[isoDate] || 0) + timeVal;
|
||||
|
||||
if (!dailyTotalsByAgent[agId]) {
|
||||
dailyTotalsByAgent[agId] = {};
|
||||
}
|
||||
dailyTotalsByAgent[agId][isoDate] = (dailyTotalsByAgent[agId][isoDate] || 0) + timeVal;
|
||||
|
||||
if (isoDate === dateStr) {
|
||||
dayArticles.push(r);
|
||||
totalMinutes += timeVal;
|
||||
@@ -2223,13 +2277,15 @@ router.get('/time-accounting/summary', async (req, res) => {
|
||||
const workDays = workDaysRaw.split(',').map(x => parseInt(x.trim(), 10)).filter(x => !isNaN(x));
|
||||
|
||||
res.json({
|
||||
agent_id: agentId,
|
||||
agent_id: agentIds[0],
|
||||
agent_ids: agentIds,
|
||||
date: dateStr,
|
||||
total_minutes: totalMinutes,
|
||||
target_minutes: targetMinutes,
|
||||
work_days: workDays,
|
||||
articles: dayArticles,
|
||||
daily_totals: dailyTotals,
|
||||
daily_totals_by_agent: dailyTotalsByAgent,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching time accounting summary:', err);
|
||||
|
||||
Reference in new issue
Block a user