diff --git a/web/app/equipment/register/page.tsx b/web/app/equipment/register/page.tsx index 93de1e4..3b9ff60 100644 --- a/web/app/equipment/register/page.tsx +++ b/web/app/equipment/register/page.tsx @@ -185,6 +185,8 @@ const PARAMETER_TYPE_OPTIONS = [ "map", ]; +const OUTPUT_TYPE_OPTIONS = [...PARAMETER_TYPE_OPTIONS, "file"]; + const PARAMETER_CSV_COLUMNS = [ "variable_name", "type", @@ -196,6 +198,15 @@ const PARAMETER_CSV_COLUMNS = [ "max_value", ]; +const OUTPUT_CSV_COLUMNS = [ + "variable_name", + "type", + "units", + "description", + "equipment_parameter_id", + "artifact", +]; + function slugifyEquipmentName(name: string) { return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); } @@ -281,6 +292,20 @@ function buildParameterTemplateCsv() { .join("\n"); } +function buildOutputTemplateCsv() { + const example = [ + "Etch_Rate", + "float", + "nm/min", + "Measured etch rate from post-process metrology", + "901", + "false", + ]; + return [OUTPUT_CSV_COLUMNS, example] + .map((row) => row.map(csvEscape).join(",")) + .join("\n"); +} + function downloadParameterTemplate() { const blob = new Blob([buildParameterTemplateCsv()], { type: "text/csv;charset=utf-8", @@ -293,6 +318,18 @@ function downloadParameterTemplate() { URL.revokeObjectURL(url); } +function downloadOutputTemplate() { + const blob = new Blob([buildOutputTemplateCsv()], { + type: "text/csv;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "equipment_outputs_template.csv"; + link.click(); + URL.revokeObjectURL(url); +} + function cloneInitialData(): FormData { return { ...INITIAL_DATA, @@ -417,6 +454,11 @@ function EquipmentRegisterContent() { message: string; } | null>(null); const [csvImportMode, setCsvImportMode] = useState<"replace" | "append">("replace"); + const [outputCsvImportResult, setOutputCsvImportResult] = useState<{ + status: "success" | "error"; + message: string; + } | null>(null); + const [outputCsvImportMode, setOutputCsvImportMode] = useState<"replace" | "append">("replace"); const isEditMode = Boolean(editDomainId); useEffect(() => { @@ -613,6 +655,120 @@ function EquipmentRegisterContent() { } }; + const importOutputCsv = async (file: File | null) => { + if (!file) return; + + try { + const text = await file.text(); + const rows = parseCsv(text); + if (rows.length < 2) { + setOutputCsvImportResult({ + status: "error", + message: "CSV must include a header row and at least one output row.", + }); + return; + } + + const header = rows[0].map((value) => value.trim().toLowerCase()); + const missingRequired = ["variable_name", "type", "units", "description"].filter( + (column) => !header.includes(column), + ); + if (missingRequired.length > 0) { + setOutputCsvImportResult({ + status: "error", + message: `Missing required CSV columns: ${missingRequired.join(", ")}.`, + }); + return; + } + + const columnIndex = (column: string) => header.indexOf(column); + const imported = rows.slice(1).map((row) => ({ + name: row[columnIndex("variable_name")]?.trim() || "", + type: row[columnIndex("type")]?.trim() || "float", + unit: row[columnIndex("units")]?.trim() || "", + description: row[columnIndex("description")]?.trim() || "", + equipment_parameter_id: + columnIndex("equipment_parameter_id") >= 0 + ? row[columnIndex("equipment_parameter_id")]?.trim() || "" + : "", + artifact: + columnIndex("artifact") >= 0 + ? csvBoolean(row[columnIndex("artifact")] || "") + : false, + })); + + const invalidRows = imported + .map((output, index) => ({ + index: index + 2, + output, + })) + .filter( + ({ output }) => + !output.name || + !output.type || + !output.unit || + !output.description, + ); + if (invalidRows.length > 0) { + setOutputCsvImportResult({ + status: "error", + message: `Rows ${invalidRows.map((row) => row.index).join(", ")} are missing required values.`, + }); + return; + } + + const existingOutputs = + outputCsvImportMode === "append" + ? formData.outputs.filter((output) => output.name.trim()) + : []; + const nextOutputs = [...existingOutputs, ...imported]; + const duplicateNames = nextOutputs + .map((output) => output.name.trim()) + .filter((name) => name) + .filter((name, index, names) => names.indexOf(name) !== index); + if (duplicateNames.length > 0) { + setOutputCsvImportResult({ + status: "error", + message: `CSV import would create duplicate output names: ${[ + ...new Set(duplicateNames), + ].join(", ")}.`, + }); + return; + } + + const duplicateEquipmentParameterIds = nextOutputs + .map((output) => output.equipment_parameter_id.trim()) + .filter((id) => id) + .filter((id, index, ids) => ids.indexOf(id) !== index); + if (duplicateEquipmentParameterIds.length > 0) { + setOutputCsvImportResult({ + status: "error", + message: `CSV import would create duplicate Eq Param IDs: ${[ + ...new Set(duplicateEquipmentParameterIds), + ].join(", ")}.`, + }); + return; + } + + setFormData((previous) => ({ + ...previous, + outputs: nextOutputs, + })); + setOutputCsvImportResult({ + status: "success", + message: + outputCsvImportMode === "append" + ? `Appended ${imported.length} output${imported.length === 1 ? "" : "s"}.` + : `Imported ${imported.length} output${imported.length === 1 ? "" : "s"}.`, + }); + } catch (error) { + setOutputCsvImportResult({ + status: "error", + message: error instanceof Error ? error.message : "Could not read CSV file.", + }); + } + }; + const addOutput = () => { setFormData((previous) => ({ ...previous, @@ -1171,15 +1327,67 @@ function EquipmentRegisterContent() { case 4: return (
- Declare the processed measurements and raw artifacts this - equipment can produce. The registrar should enter these outputs - explicitly; optimization targets will later be chosen from - experiment outputs, not from equipment registration. -
++ Declare the processed measurements and raw artifacts this + equipment can produce. The registrar should enter these outputs + explicitly; optimization targets will later be chosen from + experiment outputs, not from equipment registration. +
+