やりたかった事
あるWebサービスからCSV形式でデータをエクスポート出来るのですが、そのデータからちょっとした帳票を印刷したいという要望がありました。
そこでなるべく手っ取り早く、ブラウザだけで動くものが出来ないかと考えて作ってみたのが今回のプログラムです。
大まかな流れは、
- ローカルフォルダにあるCSVファイルを指定するとブラウザ上のJavaScriptでその内容を読み込む。
- 読み込んだ内容からHTMLで帳票を生成して表示する。
- ブラウザの印刷機能を使って手動で印刷またはPDFとして保存する。
CSVファイルの例
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
User | Client | Project | Description | Start date | Start time | End date | End time | |
---|---|---|---|---|---|---|---|---|
Mike | 株式会社◯◯◯ | 売上管理システム開発 | データ取込エラーの調査 | 2018-07-04 | 18:15:00 | 2018-07-04 | 18:45:00 | |
Mike | 株式会社◯◯◯ | 売上管理システム開発 | データ取込エラーの調査 | 2018-07-05 | 12:10:00 | 2018-07-05 | 13:31:00 | |
Mike | 株式会社◯◯◯ | 売上管理システム開発 | データ取込エラー対策の実装 | 2018-07-06 | 10:54:00 | 2018-07-06 | 11:05:00 | |
Mike | 株式会社◯◯◯ | 売上管理システム開発 | AWS・RDSの設定・動作確認 | 2018-07-13 | 14:01:57 | 2018-07-13 | 14:22:57 | |
Mike | 株式会社◯◯◯ | 売上管理システム開発 | EC2インスタンスの作成・動作確認 | 2018-07-18 | 08:10:14 | 2018-07-18 | 08:30:14 | |
Mike | △△株式会社 | iOSアプリ開発 | サーバーからの画像ダウンロード処理の変更 | 2018-07-20 | 11:55:05 | 2018-07-20 | 12:05:07 | |
Mike | △△株式会社 | iOSアプリ開発 | 画像アップロード処理の実装 | 2018-07-20 | 13:00:14 | 2018-07-20 | 13:51:48 | |
Mike | 株式会社◯◯◯ | 売上管理システム開発 | 月次レポート出力画面作成 | 2018-07-30 | 10:37:06 | 2018-07-30 | 10:59:56 | |
Mike | 株式会社◯◯◯ | 売上管理システム開発 | 月次レポート出力画面の仕様変更・テスト | 2018-07-30 | 11:27:59 | 2018-07-30 | 12:20:59 | |
Mike | 株式会社◯◯◯ | 売上管理システム開発 | 次期バージョンの追加機能についてミーティング | 2018-07-31 | 21:00:00 | 2018-07-31 | 21:41:00 |
作成したい帳票
実際に動いているもの
https://mikehibm.github.io/react-csv-example/
1. プロジェクトを作成する
プロジェクトは前回のエントリーと同様に Create React App + TypeScriptで作ることにします。
npm install -g create-react-app
create-react-app my-app --scripts-version=react-scripts-ts
cd my-app/
npm start
FileSelect.tsx と Report.tsx というファイルを新規作成して、App.tsx からこれらを呼び出して使うようにします。
最初は FileSelect コンポーネントを表示してCSVファイルの選択を行い、ボタンがクリックされたらファイルの内容を読み取って、Report コンポーネントに渡します。
Report コンポーネントが表示されている時は FileSelect コンポーネントは非表示になるようにします。
2. JavaScriptでCSVファイルの内容を読み込む
HTML5のFile APIを使います。FileReaderでの読み込みは非同期処理なので、処理をラップしてPromiseを返す関数を作りました。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function readFileAsText(file: Blob): Promise<string> { | |
return new Promise<string>((resolve, reject) => { | |
const reader = new FileReader(); | |
reader.onerror = () => reject(reader.error); | |
reader.onload = () => resolve((reader.result as string) || ''); | |
reader.readAsText(file); | |
}); | |
} |
3. ファイルの内容をパースしてオブジェクトの配列に変換する
上の readFileAsText() からファイル全体の内容が文字列として返ってくるので、それをパースしてオブジェクトの配列に変換します。
具体的には、まず改行文字で区切って行単位の配列に分け、さらにその各行について「,」で区切って列単位の配列に分けます。つまり最終的には string[][] (文字列の配列の配列)になります。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function mapCSVToArray(csv: string): string[][] { | |
return csv.split('\n').map((row) => row.split(',')); | |
} |
ここでは簡単に改行文字(\n)と「,」で区切っているだけですが、実際には改行文字が違う、値に「,」が含まれている、などさまざまなケースがあり得るのでCSVのパース処理はちゃんとやろうとすると実は結構大変です。
なので必要であれば下の記事で紹介されている csv-parser などを使った方が良いかもしれません。
ブラウザ上でCSVファイルをパースする
https://qiita.com/ledsun/items/e38ee0dff8f26bf8d930
その後、文字列の配列(の配列)から、今度は帳票を生成する時に扱いやすいように、WorkItemというクラスのインスタンスの配列に変換します。
この時に、文字列型から日付型への変換や、CSVファイルに無い項目の値(duration)の計算なども同時に行っています。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as moment from 'moment'; | |
export interface WorkItem { | |
user: string; | |
client: string; | |
project: string; | |
description: string; | |
startDate: Date; | |
endDate: Date; | |
duration: number; | |
} | |
export function mapArrayToWorkItem(data: string[][]): WorkItem[] { | |
return data | |
.map((row) => { | |
const startDate = moment(`${row[4]} ${row[5]}`, 'YYYY-MM-DD HH:mm:ss'); | |
const endDate = moment(`${row[6]} ${row[7]}`, 'YYYY-MM-DD HH:mm:ss'); | |
const duration = moment.duration(endDate.diff(startDate)); | |
return { | |
user: row[0], | |
client: row[1], | |
project: row[2], | |
description: row[3], | |
startDate: startDate.toDate(), | |
endDate: endDate.toDate(), | |
duration: duration.asHours() | |
}; | |
}) | |
.filter((i) => i.client !== 'Client' && i.client); // 先頭と末尾の行を除外。 | |
} |
4. HTMLで帳票を生成する
WorkItemオブジェクトの配列を受け取って、tableタグで表形式のHTMLに変換します。
本当は帳票のタイトル、見出し、明細行などをそれぞれ別のコンポーネントに分けた方が良いのだと思いますが、今回はあまり時間がなかったので1コンポーネントで作ってしまいました。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from 'react'; | |
import * as moment from 'moment'; | |
import { WorkItem } from './WorkItem'; | |
import './Report.css'; | |
interface Props { | |
items: WorkItem[]; | |
} | |
export const Report = (props: Props) => { | |
const totalHours = props.items.reduce<number>( | |
(prev: number, cur: WorkItem) => prev + cur.duration, | |
0 | |
); | |
const startOfMonth = moment(props.items[0].startDate).startOf('month'); | |
const endOfMonth = moment(props.items[0].startDate).endOf('month'); | |
return ( | |
<div className="Report"> | |
<h4> | |
{startOfMonth.format('YYYY-MM-DD')} ~ {endOfMonth.format('YYYY-MM-DD')} | |
</h4> | |
<table> | |
<thead> | |
<tr> | |
<th>Description</th> | |
<th>Date</th> | |
<th>Time</th> | |
<th>Hours</th> | |
</tr> | |
</thead> | |
<tbody> | |
{props.items.map((i, index) => ( | |
<tr key={index}> | |
<td className="description"> | |
<span className="project">{i.project}</span> | |
<br /> | |
{i.description} | |
</td> | |
<td>{moment(i.startDate).format('MM/DD')}</td> | |
<td className="start_time"> | |
{moment(i.startDate).format('HH:mm')}~{moment(i.endDate).format('HH:mm')} | |
</td> | |
<td className="number">{i.duration.toFixed(2)}h</td> | |
</tr> | |
))} | |
<tr> | |
<td> </td> | |
<td> </td> | |
<th>Total</th> | |
<th className="number">{totalHours.toFixed(2)}h</th> | |
</tr> | |
</tbody> | |
</table> | |
</div> | |
); | |
}; |
あとは画面に表示された帳票をブラウザの印刷機能で印刷するだけです。デフォルトではおそらく余計なヘッダーやフッター(ページタイトルやURL)も印刷されてしまいますが、印刷時の設定画面でこれらをオフにしておけばきれいに出力されるはずです。
今回作ったアプリの全ソースコードは下記にあります。
https://github.com/mikehibm/react-csv-example