feat: FASE 3 - Flusso MeasurementTec (selezione ricetta, esecuzione misure, riepilogo)
Implementazione completa del flusso operativo per il ruolo MeasurementTec:
Blueprint measure.py:
- select_recipe: selezione ricetta con ricerca e barcode
- task_list: lista task con conteggi subtask e allegati
- task_execute: esecuzione misure con numpad, calibro USB, feedback real-time
- task_complete: riepilogo con statistiche pass/fail e export CSV
- API AJAX: lookup-barcode, save-traceability, save-measurement
- Autorizzazione role_required("MeasurementTec") su tutte le route
Componenti riutilizzabili:
- numpad.html/js/css: tastierino numerico touch-friendly con keyboard support
- caliper_status.html + caliper.js: integrazione calibro USB via Web Serial API
- barcode_scanner.html + barcode.js: scansione barcode con html5-qrcode
- measurement_feedback.html: feedback visivo pass/warning/fail in tempo reale
- next_measurement.html: indicatore prossima misurazione
- annotation-viewer.js: visualizzatore canvas con marker su disegni tecnici
- csv-export.js: export CSV con locale italiano (delimitatore ;, decimale ,)
Sicurezza:
- Decoratore role_required(*roles) per autorizzazione basata su ruoli
- CSRF token su tutti i POST AJAX
- |tojson per prevenire XSS su annotations_json
- Validazione input lato client e server
i18n: 23+ nuove chiavi tradotte IT/EN per tutti i template FASE 3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Numpad Component Styles
|
||||
* Additional styles not covered by TailwindCSS utility classes
|
||||
*/
|
||||
|
||||
/* Touch optimization - prevent text selection and improve tap responsiveness */
|
||||
.numpad-container button {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Ensure touch-manipulation class works */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Prevent tap highlight on mobile devices */
|
||||
.numpad-container button:active {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Improve button focus for accessibility */
|
||||
.numpad-container button:focus-visible {
|
||||
outline: 2px solid var(--color-primary, #0D47A1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Ensure consistent sizing on all devices */
|
||||
.numpad-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Value display - ensure proper spacing and alignment */
|
||||
.value-display {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Prevent zoom on double-tap for iOS Safari */
|
||||
@media (max-width: 768px) {
|
||||
.numpad-container button {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments for better contrast */
|
||||
.dark .value-display {
|
||||
background-color: var(--bg-card, rgb(51, 65, 85));
|
||||
border-color: var(--border-color, rgb(71, 85, 105));
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Annotation Viewer - Display-only viewer for image annotations
|
||||
*
|
||||
* Alpine.js component for viewing measurement markers on technical drawings.
|
||||
* Supports point markers and rectangle markers with highlighting.
|
||||
*
|
||||
* Annotation JSON Format:
|
||||
* {
|
||||
* "markers": [
|
||||
* {
|
||||
* "id": 1,
|
||||
* "marker_number": 1,
|
||||
* "subtask_id": 42,
|
||||
* "x": 150,
|
||||
* "y": 200,
|
||||
* "type": "point",
|
||||
* "label": "D1"
|
||||
* },
|
||||
* {
|
||||
* "id": 2,
|
||||
* "marker_number": 2,
|
||||
* "subtask_id": 43,
|
||||
* "x": 300,
|
||||
* "y": 150,
|
||||
* "width": 100,
|
||||
* "height": 50,
|
||||
* "type": "rectangle",
|
||||
* "label": "L1"
|
||||
* }
|
||||
* ],
|
||||
* "imageWidth": 800,
|
||||
* "imageHeight": 600
|
||||
* }
|
||||
*
|
||||
* Usage:
|
||||
* <div x-data="annotationViewer()"
|
||||
* x-init="imageUrl = '/path/to/image.jpg'; annotations = {...}; init()">
|
||||
* <canvas x-ref="annotationCanvas" @click="handleClick($event)"></canvas>
|
||||
* </div>
|
||||
*/
|
||||
|
||||
function annotationViewer() {
|
||||
return {
|
||||
// Canvas elements
|
||||
canvas: null,
|
||||
ctx: null,
|
||||
|
||||
// Data
|
||||
annotations: null, // { markers: [...], imageWidth, imageHeight }
|
||||
imageUrl: null, // URL dell'immagine da visualizzare
|
||||
activeMarker: null, // marker_number del marker correntemente selezionato
|
||||
|
||||
// State
|
||||
imageLoaded: false,
|
||||
scale: 1, // scala di ridimensionamento immagine
|
||||
|
||||
/**
|
||||
* Inizializzazione component
|
||||
*/
|
||||
init() {
|
||||
this.canvas = this.$refs.annotationCanvas;
|
||||
if (!this.canvas) {
|
||||
console.error('AnnotationViewer: canvas ref not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
|
||||
// Load image if URL is provided
|
||||
if (this.imageUrl) {
|
||||
this.loadImage();
|
||||
}
|
||||
|
||||
// Responsive resize
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.imageLoaded) {
|
||||
this.loadImage();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Carica e renderizza l'immagine con scaling
|
||||
*/
|
||||
loadImage() {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate scale to fit container
|
||||
const container = this.canvas.parentElement;
|
||||
if (!container) {
|
||||
console.error('AnnotationViewer: parent container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight || 500; // fallback height
|
||||
|
||||
this.scale = Math.min(
|
||||
containerWidth / img.width,
|
||||
containerHeight / img.height,
|
||||
1 // non ingrandire oltre dimensione originale
|
||||
);
|
||||
|
||||
this.canvas.width = img.width * this.scale;
|
||||
this.canvas.height = img.height * this.scale;
|
||||
|
||||
// Draw image
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
|
||||
this.imageLoaded = true;
|
||||
|
||||
// Draw annotations on top
|
||||
this.drawAnnotations();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('AnnotationViewer: failed to load image:', this.imageUrl);
|
||||
};
|
||||
|
||||
img.src = this.imageUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disegna tutti i markers sulle annotazioni
|
||||
*/
|
||||
drawAnnotations() {
|
||||
if (!this.annotations || !this.annotations.markers) return;
|
||||
|
||||
for (const marker of this.annotations.markers) {
|
||||
const isActive = this.activeMarker === marker.marker_number;
|
||||
this.drawMarker(marker, isActive);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Disegna un singolo marker
|
||||
* @param {Object} marker - marker data
|
||||
* @param {boolean} isActive - se il marker è attualmente selezionato
|
||||
*/
|
||||
drawMarker(marker, isActive) {
|
||||
const x = marker.x * this.scale;
|
||||
const y = marker.y * this.scale;
|
||||
const color = isActive ? '#2563EB' : '#64748B'; // primary : steel
|
||||
const radius = isActive ? 16 : 12;
|
||||
|
||||
if (marker.type === 'point') {
|
||||
// Cerchio numerato
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = color;
|
||||
this.ctx.globalAlpha = isActive ? 0.9 : 0.7;
|
||||
this.ctx.fill();
|
||||
this.ctx.globalAlpha = 1;
|
||||
|
||||
// Bordo bianco
|
||||
this.ctx.strokeStyle = '#FFFFFF';
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.stroke();
|
||||
|
||||
// Numero
|
||||
this.ctx.fillStyle = '#FFFFFF';
|
||||
this.ctx.font = `bold ${isActive ? 14 : 11}px Inter, sans-serif`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
this.ctx.fillText(marker.marker_number.toString(), x, y);
|
||||
|
||||
} else if (marker.type === 'rectangle') {
|
||||
const w = (marker.width || 50) * this.scale;
|
||||
const h = (marker.height || 30) * this.scale;
|
||||
|
||||
// Rettangolo
|
||||
this.ctx.strokeStyle = color;
|
||||
this.ctx.lineWidth = isActive ? 3 : 2;
|
||||
this.ctx.setLineDash(isActive ? [] : [5, 3]);
|
||||
this.ctx.strokeRect(x, y, w, h);
|
||||
this.ctx.setLineDash([]);
|
||||
|
||||
// Label badge
|
||||
const badgeWidth = 30;
|
||||
const badgeHeight = 18;
|
||||
this.ctx.fillStyle = color;
|
||||
this.ctx.globalAlpha = 0.8;
|
||||
this.ctx.fillRect(x, y - 20, badgeWidth, badgeHeight);
|
||||
this.ctx.globalAlpha = 1;
|
||||
|
||||
// Numero nella badge
|
||||
this.ctx.fillStyle = '#FFFFFF';
|
||||
this.ctx.font = 'bold 11px Inter, sans-serif';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
this.ctx.fillText(marker.marker_number.toString(), x + badgeWidth / 2, y - 11);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Evidenzia un marker specifico
|
||||
* @param {number} markerNumber - numero del marker da evidenziare
|
||||
*/
|
||||
setActiveMarker(markerNumber) {
|
||||
this.activeMarker = markerNumber;
|
||||
if (this.imageLoaded) {
|
||||
// Ridisegna tutto
|
||||
this.loadImage();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Gestisce click sul canvas per selezionare markers
|
||||
* @param {MouseEvent} e - evento click
|
||||
*/
|
||||
handleClick(e) {
|
||||
if (!this.annotations || !this.annotations.markers) return;
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const clickY = e.clientY - rect.top;
|
||||
|
||||
// Check click su ogni marker (reverse order per priorità visuale)
|
||||
for (let i = this.annotations.markers.length - 1; i >= 0; i--) {
|
||||
const marker = this.annotations.markers[i];
|
||||
const mx = marker.x * this.scale;
|
||||
const my = marker.y * this.scale;
|
||||
|
||||
let hit = false;
|
||||
|
||||
if (marker.type === 'point') {
|
||||
const dist = Math.sqrt((clickX - mx) ** 2 + (clickY - my) ** 2);
|
||||
hit = dist < 20;
|
||||
} else if (marker.type === 'rectangle') {
|
||||
const w = (marker.width || 50) * this.scale;
|
||||
const h = (marker.height || 30) * this.scale;
|
||||
hit = clickX >= mx && clickX <= mx + w &&
|
||||
clickY >= my && clickY <= my + h;
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
// Dispatch event per parent component
|
||||
this.$dispatch('marker-click', {
|
||||
marker_number: marker.marker_number,
|
||||
subtask_id: marker.subtask_id,
|
||||
marker: marker
|
||||
});
|
||||
|
||||
// Auto-evidenzia
|
||||
this.setActiveMarker(marker.marker_number);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pulisce il canvas
|
||||
*/
|
||||
clear() {
|
||||
if (this.ctx && this.canvas) {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.imageLoaded = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Aggiorna le annotazioni e ridisegna
|
||||
* @param {Object} newAnnotations - nuovo oggetto annotations
|
||||
*/
|
||||
updateAnnotations(newAnnotations) {
|
||||
this.annotations = newAnnotations;
|
||||
if (this.imageLoaded) {
|
||||
this.loadImage();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Barcode Scanner Manager
|
||||
* Integration with html5-qrcode library
|
||||
* Supports QR codes, barcodes, and camera scanning
|
||||
*/
|
||||
|
||||
function barcodeScanner() {
|
||||
return {
|
||||
scanning: false,
|
||||
result: null,
|
||||
error: null,
|
||||
scanner: null,
|
||||
cameraId: null,
|
||||
|
||||
async startScan() {
|
||||
this.scanning = true;
|
||||
this.result = null;
|
||||
this.error = null;
|
||||
|
||||
// Check library availability
|
||||
if (!window.Html5Qrcode) {
|
||||
this.error = 'Scanner library not loaded';
|
||||
this.scanning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get available cameras
|
||||
const devices = await Html5Qrcode.getCameras();
|
||||
|
||||
if (!devices || devices.length === 0) {
|
||||
this.error = 'Nessuna fotocamera disponibile';
|
||||
this.scanning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer back camera
|
||||
const backCamera = devices.find(d => d.label.toLowerCase().includes('back'));
|
||||
this.cameraId = backCamera ? backCamera.id : devices[0].id;
|
||||
|
||||
// Initialize scanner
|
||||
this.scanner = new Html5Qrcode("barcode-reader");
|
||||
|
||||
// Start scanning
|
||||
await this.scanner.start(
|
||||
this.cameraId,
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
aspectRatio: 1.0
|
||||
},
|
||||
(decodedText, decodedResult) => {
|
||||
// Success callback
|
||||
this.result = decodedText;
|
||||
this.stopScan();
|
||||
|
||||
// Dispatch event for parent components
|
||||
this.$dispatch('barcode-scanned', {
|
||||
code: decodedText,
|
||||
format: decodedResult.result?.format?.formatName || 'unknown',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
},
|
||||
(errorMessage) => {
|
||||
// Error callback - ignore continuous scanning errors
|
||||
// Only log non-routine errors
|
||||
if (!errorMessage.includes('NotFoundException')) {
|
||||
console.debug('Scan frame error:', errorMessage);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Scanner initialization error:', err);
|
||||
this.error = 'Impossibile accedere alla fotocamera';
|
||||
this.scanning = false;
|
||||
}
|
||||
},
|
||||
|
||||
async stopScan() {
|
||||
if (this.scanner) {
|
||||
try {
|
||||
await this.scanner.stop();
|
||||
await this.scanner.clear();
|
||||
} catch (err) {
|
||||
console.error('Stop scan error:', err);
|
||||
}
|
||||
this.scanner = null;
|
||||
}
|
||||
this.scanning = false;
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.result = null;
|
||||
this.error = null;
|
||||
},
|
||||
|
||||
// Cleanup on component destroy
|
||||
destroy() {
|
||||
this.stopScan();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Barcode Input Helper
|
||||
* For integrating barcode scanning into input fields
|
||||
*/
|
||||
function barcodeInput() {
|
||||
return {
|
||||
value: '',
|
||||
showScanner: false,
|
||||
|
||||
openScanner() {
|
||||
this.showScanner = true;
|
||||
},
|
||||
|
||||
handleScan(event) {
|
||||
this.value = event.detail.code;
|
||||
this.showScanner = false;
|
||||
|
||||
// Dispatch value change event
|
||||
this.$dispatch('input', { value: this.value });
|
||||
this.$dispatch('change', { value: this.value });
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.value = '';
|
||||
this.$dispatch('input', { value: '' });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Caliper Connection Manager
|
||||
* Web Serial API integration for USB digital calipers
|
||||
* Supports standard SPC/Digimatic protocol
|
||||
*/
|
||||
|
||||
function caliperConnection() {
|
||||
return {
|
||||
connected: false,
|
||||
status: 'disconnected', // disconnected, connecting, connected, error
|
||||
lastReading: null,
|
||||
port: null,
|
||||
reader: null,
|
||||
readController: null,
|
||||
|
||||
get isSupported() {
|
||||
return 'serial' in navigator;
|
||||
},
|
||||
|
||||
async connect() {
|
||||
if (!this.isSupported) {
|
||||
this.status = 'error';
|
||||
console.error('Web Serial API not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.status = 'connecting';
|
||||
|
||||
// Request port selection
|
||||
this.port = await navigator.serial.requestPort();
|
||||
|
||||
// Open port with standard caliper settings
|
||||
await this.port.open({
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none'
|
||||
});
|
||||
|
||||
this.connected = true;
|
||||
this.status = 'connected';
|
||||
|
||||
// Start reading loop
|
||||
this.readLoop();
|
||||
|
||||
} catch (err) {
|
||||
this.status = 'error';
|
||||
this.connected = false;
|
||||
console.error('Caliper connection error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async readLoop() {
|
||||
if (!this.port || !this.port.readable) return;
|
||||
|
||||
const reader = this.port.readable.getReader();
|
||||
this.reader = reader;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
console.log('Reader closed');
|
||||
break;
|
||||
}
|
||||
this.parseData(value);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'NetworkError') {
|
||||
console.error('Read error:', err);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
|
||||
parseData(data) {
|
||||
try {
|
||||
// Decode buffer to text
|
||||
const text = new TextDecoder().decode(data);
|
||||
|
||||
// Standard digital caliper protocol patterns
|
||||
// Format: typically " XX.XXX mm\r\n" or similar
|
||||
// Try multiple patterns for compatibility
|
||||
|
||||
// Pattern 1: Standard with units
|
||||
let match = text.match(/([+-]?\s*\d+\.?\d*)\s*mm/i);
|
||||
|
||||
// Pattern 2: Just number with optional sign
|
||||
if (!match) {
|
||||
match = text.match(/([+-]?\s*\d+\.?\d*)/);
|
||||
}
|
||||
|
||||
if (match) {
|
||||
// Clean whitespace and parse
|
||||
const valueStr = match[1].replace(/\s+/g, '');
|
||||
const value = parseFloat(valueStr);
|
||||
|
||||
if (!isNaN(value)) {
|
||||
this.lastReading = value;
|
||||
|
||||
// Dispatch event for other components
|
||||
this.$dispatch('caliper-reading', {
|
||||
value: value,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Parse error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async disconnect() {
|
||||
try {
|
||||
// Cancel reader
|
||||
if (this.reader) {
|
||||
await this.reader.cancel();
|
||||
this.reader = null;
|
||||
}
|
||||
|
||||
// Close port
|
||||
if (this.port) {
|
||||
await this.port.close();
|
||||
this.port = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Disconnect error:', err);
|
||||
} finally {
|
||||
this.connected = false;
|
||||
this.status = 'disconnected';
|
||||
}
|
||||
},
|
||||
|
||||
// Cleanup on component destroy
|
||||
destroy() {
|
||||
this.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* CSV Export Utility
|
||||
* Client-side CSV generation and download with Italian locale support
|
||||
* Uses Blob API for efficient file generation
|
||||
*/
|
||||
|
||||
function csvExport() {
|
||||
return {
|
||||
// Locale-specific settings
|
||||
delimiter: ';', // Italian Excel standard
|
||||
decimalSeparator: ',', // Italian number format
|
||||
dateFormat: 'dd/MM/yyyy HH:mm:ss',
|
||||
|
||||
/**
|
||||
* Export measurements to CSV file
|
||||
* @param {Array} measurements - Array of measurement objects
|
||||
* @param {String} filename - Optional filename (auto-generated if not provided)
|
||||
*/
|
||||
exportMeasurements(measurements, filename = null) {
|
||||
if (!measurements || !Array.isArray(measurements) || measurements.length === 0) {
|
||||
console.warn('No measurements to export');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build CSV content
|
||||
const csv = this.buildMeasurementCSV(measurements);
|
||||
|
||||
// Download file
|
||||
this.downloadCSV(csv, filename || this.generateFilename('misure'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Export task execution summary
|
||||
* @param {Object} task - Task object with measurements
|
||||
* @param {String} filename - Optional filename
|
||||
*/
|
||||
exportTaskSummary(task, filename = null) {
|
||||
if (!task || !task.measurements || task.measurements.length === 0) {
|
||||
console.warn('No task data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const csv = this.buildTaskSummaryCSV(task);
|
||||
this.downloadCSV(csv, filename || this.generateFilename(`task_${task.id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Build CSV content for measurements
|
||||
*/
|
||||
buildMeasurementCSV(measurements) {
|
||||
const headers = [
|
||||
'ID',
|
||||
'Subtask ID',
|
||||
'Nome Sottotask',
|
||||
'Valore Misurato',
|
||||
'Unità',
|
||||
'Valore Nominale',
|
||||
'Tolleranza +',
|
||||
'Tolleranza -',
|
||||
'Scarto',
|
||||
'Esito',
|
||||
'Numero Lotto',
|
||||
'Numero Seriale',
|
||||
'Metodo Input',
|
||||
'Data Misurazione',
|
||||
'Operatore'
|
||||
];
|
||||
|
||||
let csv = headers.join(this.delimiter) + '\n';
|
||||
|
||||
for (const m of measurements) {
|
||||
const row = [
|
||||
m.id || '',
|
||||
m.subtask_id || '',
|
||||
this.escapeCsvValue(m.subtask_name || ''),
|
||||
this.formatNumber(m.value),
|
||||
this.escapeCsvValue(m.unit || 'mm'),
|
||||
this.formatNumber(m.nominal),
|
||||
this.formatNumber(m.tolerance_plus),
|
||||
this.formatNumber(m.tolerance_minus),
|
||||
this.formatNumber(m.deviation),
|
||||
m.pass_fail || '',
|
||||
this.escapeCsvValue(m.lot_number || ''),
|
||||
this.escapeCsvValue(m.serial_number || ''),
|
||||
m.input_method || '',
|
||||
this.formatDateTime(m.measured_at),
|
||||
this.escapeCsvValue(m.operator_name || '')
|
||||
];
|
||||
csv += row.join(this.delimiter) + '\n';
|
||||
}
|
||||
|
||||
return csv;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build CSV content for task summary
|
||||
*/
|
||||
buildTaskSummaryCSV(task) {
|
||||
// Header section
|
||||
let csv = 'RIEPILOGO ESECUZIONE TASK\n\n';
|
||||
|
||||
csv += `Task ID${this.delimiter}${task.id}\n`;
|
||||
csv += `Nome Task${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
|
||||
csv += `Ricetta${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
|
||||
csv += `Operatore${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
|
||||
csv += `Data Inizio${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
|
||||
csv += `Data Fine${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
|
||||
csv += `Stato${this.delimiter}${task.status || ''}\n`;
|
||||
csv += `Lotto${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
|
||||
csv += `Seriale${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
|
||||
|
||||
// Statistics
|
||||
csv += `\nSTATISTICHE\n`;
|
||||
const stats = this.calculateStats(task.measurements);
|
||||
csv += `Totale Misure${this.delimiter}${stats.total}\n`;
|
||||
csv += `Passate${this.delimiter}${stats.passed}\n`;
|
||||
csv += `Fallite${this.delimiter}${stats.failed}\n`;
|
||||
csv += `Percentuale Successo${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
|
||||
|
||||
// Measurements detail
|
||||
csv += `\nDETTAGLIO MISURE\n`;
|
||||
csv += this.buildMeasurementCSV(task.measurements);
|
||||
|
||||
return csv;
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate statistics from measurements
|
||||
*/
|
||||
calculateStats(measurements) {
|
||||
const total = measurements.length;
|
||||
const passed = measurements.filter(m => (m.pass_fail || '').toLowerCase() === 'pass').length;
|
||||
const failed = total - passed;
|
||||
const passRate = total > 0 ? (passed / total * 100) : 0;
|
||||
|
||||
return { total, passed, failed, passRate };
|
||||
},
|
||||
|
||||
/**
|
||||
* Download CSV content as file
|
||||
*/
|
||||
downloadCSV(csvContent, filename) {
|
||||
// Add UTF-8 BOM for Excel compatibility
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Format number with Italian decimal separator
|
||||
*/
|
||||
formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const num = typeof value === 'number' ? value : parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert to string and replace decimal separator
|
||||
return num.toString().replace('.', this.decimalSeparator);
|
||||
},
|
||||
|
||||
/**
|
||||
* Format datetime for Italian locale
|
||||
*/
|
||||
formatDateTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
|
||||
} catch (err) {
|
||||
console.error('Date format error:', err);
|
||||
return dateStr;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Escape CSV value (handle quotes, delimiters)
|
||||
*/
|
||||
escapeCsvValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const str = String(value);
|
||||
|
||||
// If contains delimiter, newline, or quote, wrap in quotes
|
||||
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('"')) {
|
||||
// Escape existing quotes by doubling them
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
|
||||
return str;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate filename with timestamp
|
||||
*/
|
||||
generateFilename(prefix) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
||||
|
||||
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV Export Button Component
|
||||
* Reusable Alpine component for export functionality
|
||||
*/
|
||||
function csvExportButton() {
|
||||
return {
|
||||
exporting: false,
|
||||
|
||||
async exportData(data, type = 'measurements') {
|
||||
this.exporting = true;
|
||||
|
||||
try {
|
||||
const exporter = csvExport();
|
||||
|
||||
if (type === 'measurements') {
|
||||
exporter.exportMeasurements(data);
|
||||
} else if (type === 'task') {
|
||||
exporter.exportTaskSummary(data);
|
||||
}
|
||||
|
||||
// Show success feedback
|
||||
this.$dispatch('export-success', { type });
|
||||
|
||||
} catch (err) {
|
||||
console.error('Export error:', err);
|
||||
this.$dispatch('export-error', { error: err.message });
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Numpad Component - Alpine.js component for touch-friendly numeric input
|
||||
* Used for measurement data entry in task_execute.html
|
||||
*/
|
||||
|
||||
function numpad() {
|
||||
return {
|
||||
// State
|
||||
value: '', // String representation of the current value
|
||||
negative: false, // Whether the value is negative
|
||||
hasDecimal: false, // Whether a decimal point has been entered
|
||||
unit: 'mm', // Unit of measurement (can be set externally)
|
||||
maxIntDigits: 6, // Maximum integer digits
|
||||
maxDecDigits: 6, // Maximum decimal digits
|
||||
|
||||
/**
|
||||
* Get the display value with sign
|
||||
*/
|
||||
get displayValue() {
|
||||
if (!this.value) return '';
|
||||
return (this.negative ? '-' : '') + this.value;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the numeric value as a number
|
||||
*/
|
||||
get numericValue() {
|
||||
if (!this.value) return null;
|
||||
const v = parseFloat(this.value);
|
||||
return this.negative ? -v : v;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a valid value has been entered
|
||||
*/
|
||||
get hasValue() {
|
||||
return this.value.length > 0 && this.value !== '.';
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a digit to the current value
|
||||
* @param {string} d - The digit to add (0-9)
|
||||
*/
|
||||
addDigit(d) {
|
||||
// Validate: don't exceed max digits
|
||||
const parts = this.value.split('.');
|
||||
|
||||
if (this.hasDecimal) {
|
||||
// Check decimal part length
|
||||
if (parts[1] && parts[1].length >= this.maxDecDigits) return;
|
||||
} else {
|
||||
// Check integer part length
|
||||
if (parts[0] && parts[0].length >= this.maxIntDigits) return;
|
||||
}
|
||||
|
||||
// Prevent leading zeros (except "0.")
|
||||
if (this.value === '0' && d !== '.') {
|
||||
this.value = d;
|
||||
return;
|
||||
}
|
||||
|
||||
this.value += d;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a decimal point to the current value
|
||||
*/
|
||||
addDecimal() {
|
||||
// Don't add decimal if one already exists
|
||||
if (this.hasDecimal) return;
|
||||
|
||||
// If value is empty, start with "0."
|
||||
if (!this.value) this.value = '0';
|
||||
|
||||
this.value += '.';
|
||||
this.hasDecimal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the sign of the current value
|
||||
*/
|
||||
toggleSign() {
|
||||
if (!this.value) return;
|
||||
this.negative = !this.negative;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove the last character from the current value
|
||||
*/
|
||||
backspace() {
|
||||
if (!this.value) return;
|
||||
|
||||
const removed = this.value.charAt(this.value.length - 1);
|
||||
|
||||
// If removing decimal point, update flag
|
||||
if (removed === '.') this.hasDecimal = false;
|
||||
|
||||
this.value = this.value.slice(0, -1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all entered data
|
||||
*/
|
||||
clearAll() {
|
||||
this.value = '';
|
||||
this.negative = false;
|
||||
this.hasDecimal = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm the current value and dispatch event
|
||||
*/
|
||||
confirm() {
|
||||
if (!this.hasValue) return;
|
||||
|
||||
const val = this.numericValue;
|
||||
|
||||
// Dispatch custom event for parent component to handle
|
||||
this.$dispatch('numpad-confirm', { value: val });
|
||||
|
||||
// Reset after confirmation
|
||||
this.clearAll();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a value programmatically (e.g., from USB caliper)
|
||||
* @param {number} val - The numeric value to set
|
||||
*/
|
||||
setValue(val) {
|
||||
this.clearAll();
|
||||
|
||||
// Handle negative values
|
||||
if (val < 0) {
|
||||
this.negative = true;
|
||||
val = Math.abs(val);
|
||||
}
|
||||
|
||||
this.value = val.toString();
|
||||
|
||||
// Update decimal flag if value contains decimal point
|
||||
if (this.value.includes('.')) this.hasDecimal = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the unit of measurement
|
||||
* @param {string} newUnit - The unit to set (e.g., 'mm', 'cm', 'in')
|
||||
*/
|
||||
setUnit(newUnit) {
|
||||
this.unit = newUnit;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle keyboard input for physical keyboard support
|
||||
* @param {KeyboardEvent} e - The keyboard event
|
||||
*/
|
||||
handleKeydown(e) {
|
||||
// Number keys
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
this.addDigit(e.key);
|
||||
}
|
||||
// Decimal point (both . and ,)
|
||||
else if (e.key === '.' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
this.addDecimal();
|
||||
}
|
||||
// Backspace
|
||||
else if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
this.backspace();
|
||||
}
|
||||
// Escape - clear all
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.clearAll();
|
||||
}
|
||||
// Enter - confirm
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.confirm();
|
||||
}
|
||||
// Minus sign - toggle sign
|
||||
else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
this.toggleSign();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user