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:
Adriano
2026-02-07 08:40:58 +01:00
parent edd4580a5a
commit a386986c17
19 changed files with 3905 additions and 16 deletions
+273
View File
@@ -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();
}
}
};
}
+132
View File
@@ -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: '' });
}
};
}
+142
View File
@@ -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();
}
};
}
+268
View File
@@ -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;
}
}
};
}
+189
View File
@@ -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();
}
}
};
}