a829bf3eb0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
/**
|
|
* CSV Export Utility
|
|
* Client-side CSV generation and download with Italian locale support
|
|
* Uses Blob API for efficient file generation
|
|
*/
|
|
|
|
function csvExport() {
|
|
const _t = (key) => (window.CSV_I18N && window.CSV_I18N[key]) || key;
|
|
|
|
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',
|
|
_t('subtask_id'),
|
|
_t('subtask_name'),
|
|
_t('measured_value'),
|
|
_t('unit'),
|
|
_t('nominal_value'),
|
|
_t('tolerance_plus'),
|
|
_t('tolerance_minus'),
|
|
_t('deviation'),
|
|
_t('result'),
|
|
_t('lot_number'),
|
|
_t('serial_number'),
|
|
_t('input_method'),
|
|
_t('measurement_date'),
|
|
_t('operator')
|
|
];
|
|
|
|
let csv = headers.join(this.delimiter) + '\n';
|
|
|
|
for (const m of measurements) {
|
|
// MeasurementResponse (src/backend/models/api/measurement.py) carries
|
|
// none of subtask_name/unit/nominal/tolerance_plus/tolerance_minus -
|
|
// that data lives nested under m.subtask (attached server-side in
|
|
// measure.py's task_complete(), see subtask/task_info enrichment).
|
|
// Reading the flat, nonexistent names is why every one of these
|
|
// columns came out empty (#31).
|
|
const subtask = m.subtask || {};
|
|
const nominal = subtask.nominal;
|
|
const tolerancePlus = this.hasNumber(subtask.utl) && this.hasNumber(nominal)
|
|
? subtask.utl - nominal : null;
|
|
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
|
|
? nominal - subtask.ltl : null;
|
|
|
|
const row = [
|
|
m.id || '',
|
|
m.subtask_id || '',
|
|
this.escapeCsvValue(subtask.description || ''),
|
|
this.formatNumber(m.value),
|
|
this.escapeCsvValue(subtask.unit || 'mm'),
|
|
this.formatNumber(nominal),
|
|
this.formatNumber(tolerancePlus),
|
|
this.formatNumber(toleranceMinus),
|
|
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),
|
|
// No operator name anywhere on this object (or on
|
|
// MeasurementResponse) - measured_by is the real field, the
|
|
// user's id who took the reading.
|
|
this.escapeCsvValue(this.hasNumber(m.measured_by) ? m.measured_by : '')
|
|
];
|
|
csv += row.join(this.delimiter) + '\n';
|
|
}
|
|
|
|
return csv;
|
|
},
|
|
|
|
/**
|
|
* Build CSV content for task summary
|
|
*/
|
|
buildTaskSummaryCSV(task) {
|
|
// Header section
|
|
let csv = _t('task_summary_title') + '\n\n';
|
|
|
|
csv += `${_t('task_id')}${this.delimiter}${task.id}\n`;
|
|
csv += `${_t('task_name')}${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
|
|
csv += `${_t('recipe')}${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
|
|
csv += `${_t('operator')}${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
|
|
csv += `${_t('start_date')}${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
|
|
csv += `${_t('end_date')}${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
|
|
csv += `${_t('status')}${this.delimiter}${task.status || ''}\n`;
|
|
csv += `${_t('lot')}${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
|
|
csv += `${_t('serial')}${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
|
|
|
|
// Statistics
|
|
csv += `\n${_t('statistics')}\n`;
|
|
const stats = this.calculateStats(task.measurements);
|
|
csv += `${_t('total_measurements')}${this.delimiter}${stats.total}\n`;
|
|
csv += `${_t('passed')}${this.delimiter}${stats.passed}\n`;
|
|
csv += `${_t('failed')}${this.delimiter}${stats.failed}\n`;
|
|
csv += `${_t('pass_rate')}${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
|
|
|
|
// Measurements detail
|
|
csv += `\n${_t('measurement_details')}\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);
|
|
},
|
|
|
|
/**
|
|
* True for a value that is actually a usable number (not null/undefined/NaN).
|
|
*/
|
|
hasNumber(value) {
|
|
return typeof value === 'number' && !Number.isNaN(value);
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* True when the first non-space character opens a formula/command that
|
|
* Excel or LibreOffice would evaluate on open (#22): =, +, -, @, TAB, CR.
|
|
* Same check as the backend's neutralise_csv_cell
|
|
* (production_export_service.py) - only leading ASCII spaces are
|
|
* stripped before looking at the first character.
|
|
*/
|
|
_startsCsvDangerous(str) {
|
|
const stripped = str.replace(/^ +/, '');
|
|
return stripped.length > 0 && /^[=+\-@\t\r]/.test(stripped);
|
|
},
|
|
|
|
/**
|
|
* Escape CSV value (handle quotes, delimiters, formula injection)
|
|
*/
|
|
escapeCsvValue(value) {
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
|
|
let str = String(value);
|
|
|
|
// Formula/command injection (#22): a leading apostrophe forces the
|
|
// cell to stay literal text instead of being evaluated. Only for
|
|
// free-text fields - this function is never used on numeric cells
|
|
// (those go through formatNumber).
|
|
if (this._startsCsvDangerous(str)) {
|
|
str = "'" + str;
|
|
}
|
|
|
|
// If contains delimiter, newline, CR, or quote, wrap in quotes
|
|
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('\r') || 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.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
|
|
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;
|
|
}
|
|
}
|
|
};
|
|
}
|