前回(第4回)は、Excelの生データから「見出し行」を動的に探し出し、必要なデータだけを綺麗に抽出するロジックを実装した。
しかし、現状の画面はデータを単なるリスト(箇条書き)で表示しているだけで、取り込んだデータにOCRの誤字があった場合や、不要な項目を取り除きたい場合に手を加えることができない。
そこで今回は、画面のUIを箇条書きから「テーブル(表)」へと変更し、画面上で直接数値を書き換えたり、不要な項目を削除したりできる編集機能を実装していく。
1. データを編集・削除するための関数を作成する
まずは、Reactが管理しているState(uploadedFiles)の特定の項目を更新するための関数を2つ作成する。
Reactでは、Stateの配列の中身を直接書き換える(例:array[0] = "new")ことはご法度とされている。必ず「配列のコピーを作成し、コピーを書き換えてから、新しいStateとしてセットする」という手順を踏む必要がある。
// 1. 個別項目の数値を編集する関数
// どのファイルの(fileIndex)、どの項目を(recordIndex)、どんな値に(newValue)するのかを受け取る
const handleEditValue = (fileIndex: number, recordIndex: number, newValue: string) => {
// スプレッド構文(...)で現在の配列のコピーを作成
const newFiles = [...uploadedFiles];
// コピーした配列の該当箇所の値を上書き
newFiles[fileIndex].records[recordIndex].value = newValue;
// 新しい配列としてStateにセット
setUploadedFiles(newFiles);
};
// 2. 個別項目を削除する関数
const handleDeleteRecord = (fileIndex: number, recordIndex: number) => {
const newFiles = [...uploadedFiles];
// spliceメソッドを使って、指定したインデックスから1つ分のデータを配列から削除
newFiles[fileIndex].records.splice(recordIndex, 1);
setUploadedFiles(newFiles);
};2. 画面のUIをリストから「テーブル」へ変更する
編集や削除を行いやすくするため、これまでの <ul> と <li> を使った箇条書きのデザインを廃止し、HTMLの <table> タグを使って本格的な表組みを作成する。
【変更前(箇条書き)】
<ul style={{ maxHeight: '150px', overflowY: 'auto' }}>
{fileInfo.records.map((record, rIndex) => (
<li key={rIndex}>
{record.itemName}: {record.value} {record.unit}
</li>
))}
</ul>【変更後(テーブル構成の枠組み)】
<div style={{ padding: '15px', borderTop: '1px solid #ddd' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc', backgroundColor: '#f9f9f9' }}>
<th style={{ padding: '10px' }}>検査項目</th>
<th style={{ padding: '10px' }}>結果 (数値)</th>
<th style={{ padding: '10px' }}>単位</th>
<th style={{ padding: '10px' }}>操作</th>
</tr>
</thead>
<tbody>
{/* ここにデータの中身を展開する */}
</tbody>
</table>
</div>3. 入力フォームと削除ボタンの組み込み
テーブルの枠組み(<tbody> の中)に、実際にデータを展開していく。 その際、「結果」の列には <input> タグを配置して先ほどの handleEditValue 関数を紐づけ、「操作」の列には削除ボタンを配置して handleDeleteRecord 関数を紐づける。
<tbody>
{fileInfo.records.map((record, recordIndex) => (
<tr key={recordIndex} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '10px' }}>{record.itemName}</td>
<td style={{ padding: '10px' }}>
{/* 値を直接書き換えられる入力フォーム */}
<input
type="text"
value={record.value}
onChange={(e) => handleEditValue(fileIndex, recordIndex, e.target.value)}
style={{ padding: '5px', width: '80px' }}
/>
</td>
<td style={{ padding: '10px' }}>{record.unit}</td>
<td style={{ padding: '10px' }}>
{/* 不要な行を消すための削除ボタン */}
<button
onClick={() => handleDeleteRecord(fileIndex, recordIndex)}
style={{ padding: '5px 10px', backgroundColor: '#ff4d4f', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
削除
</button>
</td>
</tr>
))}
</tbody>4. 完成したコード全体(App.tsx)
ここまでの改修を反映した、第5回時点での App.tsx の全体コードがこちらだ。上手く動作しない場合は、以下のコードで上書きしてみてほしい。
// src/App.tsx
import React, { useState } from 'react';
import * as XLSX from 'xlsx';
export interface BloodTestRecord {
itemName: string;
value: number | string;
unit: string;
}
export interface UploadedFile {
fileName: string;
records: BloodTestRecord[];
}
export default function App() {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const extractBloodTestData = (rawData: any[][]): BloodTestRecord[] => {
const headerRowIndex = rawData.findIndex(row => row.includes("検査項目") || row.includes("項目名"));
if (headerRowIndex === -1) return [];
const headerRow = rawData[headerRowIndex];
const nameIndex = headerRow.findIndex((cell: any) => typeof cell === 'string' && (cell.includes("検査項目") || cell.includes("項目名")));
const valueIndex = headerRow.findIndex((cell: any) => typeof cell === 'string' && cell.includes("結果"));
const unitIndex = headerRow.findIndex((cell: any) => typeof cell === 'string' && cell.includes("単位"));
const records: BloodTestRecord[] = [];
for (let i = headerRowIndex + 1; i < rawData.length; i++) {
const row = rawData[i];
if (!row || !row[nameIndex]) continue;
records.push({
itemName: String(row[nameIndex]).trim(),
value: row[valueIndex] !== undefined ? row[valueIndex] : '',
unit: row[unitIndex] || '',
});
}
return records;
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = (event) => {
const bstr = event.target?.result;
const workbook = XLSX.read(bstr, { type: 'binary' });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1 });
const formattedRecords = extractBloodTestData(rawData);
const dateLabel = file.name.replace('.xlsx', '').replace('.xls', '');
setUploadedFiles(prev => [...prev, { fileName: dateLabel, records: formattedRecords }]);
};
reader.readAsBinaryString(file);
});
e.target.value = '';
};
// ★追加:個別項目の数値を編集する関数
const handleEditValue = (fileIndex: number, recordIndex: number, newValue: string) => {
const newFiles = [...uploadedFiles];
newFiles[fileIndex].records[recordIndex].value = newValue;
setUploadedFiles(newFiles);
};
// ★追加:個別項目を削除する関数
const handleDeleteRecord = (fileIndex: number, recordIndex: number) => {
const newFiles = [...uploadedFiles];
newFiles[fileIndex].records.splice(recordIndex, 1);
setUploadedFiles(newFiles);
};
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '800px', margin: '0 auto' }}>
<h1>血液検査記録アプリ(プロトタイプ)</h1>
<div style={{ marginBottom: '30px', padding: '20px', backgroundColor: '#f0f4f8', borderRadius: '8px' }}>
<p style={{ marginTop: 0, fontWeight: 'bold' }}>Excelファイルを選択(複数選択可):</p>
<input type="file" accept=".xlsx, .xls" multiple onChange={handleFileUpload} />
</div>
{uploadedFiles.map((fileInfo, fileIndex) => (
<div key={fileIndex} style={{ marginBottom: '20px', border: '1px solid #ddd', borderRadius: '8px', overflow: 'hidden' }}>
<div style={{ padding: '15px 20px', backgroundColor: '#f9f9f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '18px', fontWeight: 'bold' }}>📅 {fileInfo.fileName}</span>
<span style={{ color: '#555', fontWeight: 'bold' }}>{fileInfo.records.length} 項目収録</span>
</div>
<div style={{ padding: '15px', borderTop: '1px solid #ddd', backgroundColor: '#fff' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc', backgroundColor: '#f9f9f9' }}>
<th style={{ padding: '10px' }}>検査項目</th>
<th style={{ padding: '10px' }}>結果 (数値)</th>
<th style={{ padding: '10px' }}>単位</th>
<th style={{ padding: '10px' }}>操作</th>
</tr>
</thead>
<tbody>
{fileInfo.records.map((record, recordIndex) => (
<tr key={recordIndex} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '10px' }}>{record.itemName}</td>
<td style={{ padding: '10px' }}>
<input
type="text"
value={record.value}
onChange={(e) => handleEditValue(fileIndex, recordIndex, e.target.value)}
style={{ padding: '5px', width: '80px' }}
/>
</td>
<td style={{ padding: '10px' }}>{record.unit}</td>
<td style={{ padding: '10px' }}>
<button
onClick={() => handleDeleteRecord(fileIndex, recordIndex)}
style={{ padding: '5px 10px', backgroundColor: '#ff4d4f', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
削除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
);
}ブラウザを更新してExcelを読み込んでみよう。データが綺麗なテーブル状に表示され、数値のテキストボックスを書き換えたり、赤い削除ボタンで不要な行を消したりできるようになっているはずだ。

これでデータを取り込んで調整する「前準備」が整った。次回(第6回)は、この整ったデータを使って、いよいよ本命である「Rechartsによる時系列グラフの描画」と「表記揺れの吸収」へと進んでいく。グしたこのデータを使って、いよいよ recharts ライブラリで時系列の折れ線グラフを描画していく。



コメント