React.js+TypeScript+Firebaseで家計簿アプリを作ってみた

# 背景

Firebaseに慣れるために簡単な家計簿アプリを作ってみたのでアウトプットしておくことにする

これは以前作成したToDoアプリ( https://qiita.com/kashimuuuuu/items/0cc99820d120aae473fe )を応用させたものなので、学習中の方はToDoアプリから作成することをオススメする


# 開発するアプリについて

- 家計簿アプリ
- CRUD機能は「表示」「追加」「削除」のみ
- ユーザは「支出 or 収入」「年月日」「取引内容」「金額」を入力して「＋」ボタンを押下することでデータが登録される
- 支出、収入それぞれ月単位で折れ線グラフ化する(折れ線グラフ 上:支出 下:収入)
- 見た目は特に装飾していないので各自CSSでカスタマイズすること
- 折れ線グラフはReactライブラリ「Recharts」で各自カスタマイズすること

![スクリーンショット 2022-03-10 11.57.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/cc8aacd1-90b5-e4c2-1769-10df340c9021.png)

![スクリーンショット 2022-03-10 11.57.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/d2db90ca-d26f-605d-c780-2722b17463a1.png)


# 開発言語/フレームワーク

- フロントエンド
  - React.js
  - TypeScript


- バックエンド
  - Firebase
  - Node.js

※ちなみに今回はFirebaseの無料プランを選択


# 開発手順

## 準備：Node.jsのインストール(まだNode.jsをインストールしていない方のみ)

こちらからNode.jsをインストールする

https://nodejs.org/en/


## 準備：React+TypeScriptアプリの雛形を作成する

- ターミナルにて下記コマンドを実行して、React+TypeScriptアプリの雛形を作成する
※プロジェクト名が「kashikojin1」 の場合

```
npx create-react-app kashikojin1 --template typescript
```

- ターミナルにて、上記で作成したkashikojin1配下のpackage.josnのあるディレクトリで、下記コマンドを実行して、FirebaseのSDKとルーティング用のパッケージをインストールする

```
npm install firebase react-router-dom @types/react-router-dom
```

- React + TypeScriptのアプリを立ち上げてみるƒ
ターミナルにて、package.josnのあるディレクトリで、下記コマンドを実行するとChromeでアプリが起動する

```
npm run start
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/b8928b67-4f9d-230b-1cd6-169855939bc7.png)



## 準備：Reactライブラリのインストール


- Material-UIのインストール

```
npm install @mui/material material-ui/core
```

- Rechartsのインストール

```
npm install recharts
```

## 準備：Firebaseのプロジェクトを作成する

- Firebaseのアカウントを作成する
Firebase公式HPからアカウントを作成する
https://firebase.google.com

- Firebaseのプロジェクトを作成する
赤で囲った部分をクリックしてプロジェクトを作成する
※細かい手順は省略

![スクリーンショット 2022-03-08 23.13.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/89aafa3a-1e56-5fc2-bbbd-efe7a64b68c4.png)

- Authenticationメニューから認証機能を有効化する(不要かも)
作成したプロジェクトの「Authentication」メニューから「Sign-in method」タブを選択して「メール/パスワード」と「Google」の認証を有効にする
※細かい手順は省略

![スクリーンショット 2022-03-08 23.28.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/be13b322-1fbe-4016-d603-208e2fe8e7d9.png)

- ウェブアプリを追加する
プロジェクトのホーム画面で「アプリを追加」ボタンをクリックしてWebアプリを追加する。アプリを追加するとFirebaseに接続するためのAPIキー等の情報を取得できる。これらの情報は次に作成するReactアプリで利用する

![スクリーンショット 2022-03-09 0.08.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/e40fadd0-3c4e-a6e5-eb8e-62bf1de03157.png)


![スクリーンショット 2022-03-09 12.25.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/67b6fa9b-a559-78d7-8566-5581d921967b.png)

- Cloud Firestoreにてコレクションとドキュメントを作成する
この記事( https://reffect.co.jp/react/react-crud-firebase-9#Cloud_Firestore )の「Cloud Firestoreのデータベースの作成」を参考にしながらデータベースを作成する


## 実装

まずはFirebaseの「Firestore Database」にてコレクション(データベースでいうテーブル)とドキュメント(データベースでいうレコード)を作成する

- コレクション：「tHouseholdAccountbook」という名称で作成
- ドキュメント：ドキュメントIDは適当でOK
「type(number型)」「content(string型)」「amount(number型)」「timestamp(string型)」を持ったドキュメントを1件作成する

![スクリーンショット 2022-03-10 10.10.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/758310/fef9fab7-7e6e-3022-0acb-06e91bceb452.png)

React.js+TypeScriptアプリへ下記を追加する

```typescript:src/App.tsx
import './App.css';
import TaskManagement from './components/HouseholdAccountbook'

function App() {
  return (
    <HouseholdAccountbook />
  );
}

export default App;
```

```typescript:src/components/HouseholdAccountbook.tsx
import { useState, useEffect }  from 'react';
import { db } from '../../firebase';
import CommonDialog from '../CommonDialog';
import DateSelector from '../DateSelector'
import { doc, getDocs, collection, addDoc, deleteDoc } from 'firebase/firestore';
import { Button, TextField, MenuItem } from '@mui/material'
import Select, { SelectChangeEvent } from '@mui/material/Select';
import {
  Typography,
  TableContainer,
  Table,
  TableHead,
  TableBody,
  TableRow,
  TableCell,
  makeStyles
} from '@material-ui/core'
import 'react-tabs/style/react-tabs.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { LineChart, Line, CartesianGrid, XAxis, YAxis, Tooltip } from 'recharts';

const useStyle = makeStyles((theme) => ({
  amount: {
    textAlign: 'right',
  },
  textRed: {
    color: 'red',
  },
}));

type House = {
  docId: string;
  type: string;
  content: string;
  amount: number;
  timestamp: string;
};

type ChartPropety = {
  name: string;
  uv: number;
  pv: number;
  amt: number;
};

type ChartMonthPropety = {
  month: string;
  expenditure: number;
  income: number;
};

function HouseholdAccountbook() {
  const classes = useStyle();
  const [houseList, setHouseList] = useState<House[]>([]);
  const [houseText, setHouseText] = useState<string>('');
  const [houseAmount, setHouseAmount] = useState<string>('0');
  const [isOpenDeleteConfirm, setIsOpenDeleteConfirm] = useState(false);
  const [comboItem , setComboItem] = useState('0');
  const [timestamp, setTimestamp] = useState(new Date());
  const [chartDataList, setChartDataList] = useState<ChartPropety[]>([]);  // チャート情報(データ単位)
  const [chartMonthList, setChartMonthList] = useState<ChartMonthPropety[]>([]);  // チャート情報(月単位)
  const [errMsgText, setErrMsgText] = useState<string>('');
  const [errMsgAmount, setErrMsgAmount] = useState<string>('');
  const [deleteDocId, setDeleteDocId] = useState<string>('');

  // 表示
  const dispData = () => {
    const houseCollectionRef = collection(db, 'tHouseholdAccountbook');
    getDocs(houseCollectionRef).then((querySnapshot) => {
      const userList: House[] = [];
      const wkchartDataList: ChartPropety[] = [];
      const wkchartMonthList: ChartMonthPropety[] = [];
      let count: number = 0;
      querySnapshot.docs.map((doc, index) => {
        const task: House = {
          docId: doc.id,
          type: doc.data().type.toString(),
          content: doc.data().content,
          amount: doc.data().amount,
          timestamp: doc.data().timestamp,
        };
        userList.push(task);
        count += 1;

        console.log(doc.data());

        // チャート情報(データ単位)へ追加
        wkchartDataList.push(
          {
            name: doc.data().timestamp,
            uv: doc.data().amount,
            pv: 1000,
            amt: 3000,
          }
        );

        // チャート情報(月単位)へ追加
        // ここでwkchartDataListをループして、月単位で支出/収入を保持する
        let addFlg: boolean = false;  // true:既に年月のkeyがある false:年月のkeyがない
        let wkIndex: number = 0;
        for (let i = 0; i < wkchartMonthList.length; i++) {
          if (wkchartMonthList[i].month.substring(0, 7) === doc.data().timestamp.substring(0, 7)) {
            addFlg = true;
            wkIndex = i;
          }
        };

        if (addFlg === true) {
          if (doc.data().type.toString() === '0') {
            // 支出
            wkchartMonthList[wkIndex].expenditure = wkchartMonthList[wkIndex].expenditure + doc.data().amount;
          } else {
            // 収入
            wkchartMonthList[wkIndex].income = wkchartMonthList[wkIndex].income + doc.data().amount;
          }
        } else {
          if (doc.data().type.toString() === '0') {
            // 支出
            wkchartMonthList.push(
              {
                month: doc.data().timestamp.substring(0, 7),
                expenditure: doc.data().amount,
                income: 0,
              }
            );
          } else {
            // 収入
            wkchartMonthList.push(
              {
                month: doc.data().timestamp.substring(0, 7),
                expenditure: 0,
                income: doc.data().amount,
              }
            );
          }
        }
      });

      wkchartMonthList.sort(function(a, b): number {
        if (a.month > b.month) return 1;
        if (a.month < b.month) return -1;
        return -1;
      });

      setHouseList(userList);
      setChartDataList(wkchartDataList);
      setChartMonthList(wkchartMonthList);
    });
  };

  // 数値チェック
  function isNumeric(val: string) {
    return /^-?\d+$/.test(val);
  }

  // 登録
  const addTask = (type: string, inputText: string, inputAmount: string, timestamp: Date)  => {
    let errMsg: string = '';

    setErrMsgText('');
    setErrMsgAmount('');
    
    if (inputText === '' || inputAmount === '' || isNumeric(inputAmount) === false) {
      if (inputText === '' ) {
        errMsg = errMsg + '内容を入力してください';
        setErrMsgText(errMsg);
      };
      if (inputAmount === '' || isNumeric(inputAmount) === false) {
        if (inputAmount === '') {
          errMsg = '金額を入力してください';
          setErrMsgAmount(errMsg);
        } else {
          errMsg = '金額は半角数字を入力してください';
          setErrMsgAmount(errMsg);
        };
      };
      return;
    }

    const houseCollectionRef = collection(db, 'tHouseholdAccountbook');
    const documentRef = addDoc(houseCollectionRef, {
      type: type,
      content: inputText,
      amount: Number(inputAmount),
      timestamp: `${timestamp.getFullYear()}/${(timestamp.getMonth()+1).toString().length <= 1 ? '0' + (timestamp.getMonth()+1) : timestamp.getMonth()+1}/${timestamp.getDate().toString().length <= 1 ? '0' + timestamp.getDate() : timestamp.getDate()}`,
    });
    
    setHouseText('');
    setHouseAmount('0');
    dispData();
  };

  // 削除(確認)
  const deleteTaskConfirm = (docId: string) => {
    setDeleteDocId(docId);
    setIsOpenDeleteConfirm(true);
  };

  // 削除
  const deleteTask = async() => {
    setIsOpenDeleteConfirm(false);
    const userDocumentRef = doc(db, 'tHouseholdAccountbook', deleteDocId);
    await deleteDoc(userDocumentRef);
    dispData();
  };

  // 支出/収入コンボボックス切替時
  const handleChange = (event: SelectChangeEvent) => {
    setComboItem(event.target.value);
  };

  // 初期処理
  useEffect(() => {
    dispData();
  }, []);

  return (
    <>
      <TableContainer>
        <Table>
          <TableHead>
            <TableRow>
            <TableCell>
                支出 / 収入
              </TableCell>
              <TableCell>
                年月日
              </TableCell>
              <TableCell>
                内容
              </TableCell>
              <TableCell>
                科目
              </TableCell>
              <TableCell>
              </TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {houseList.map((house, index) => (
              <>
                <TableRow key={index.toString()}>
                  <TableCell>
                    {house.type === '0' ? '支出' : '収入'}
                  </TableCell>
                  <TableCell>
                    {house.timestamp}
                  </TableCell>
                  <TableCell>
                    {house.content}
                  </TableCell>
                  <TableCell>
                    {house.amount.toString()}円
                  </TableCell>
                  <TableCell>
                    <Button
                      variant="outlined"
                      color="error"
                      onClick={() => deleteTaskConfirm(house.docId)}
                    >
                      <FontAwesomeIcon
                        icon={faTrashAlt}
                        fixedWidth
                      />
                    </Button>
                  </TableCell>
                </TableRow>
                <CommonDialog
                  msg="この記録を削除しますか？"
                  isOpen={isOpenDeleteConfirm}
                  doYes={deleteTask}
                  doNo={() => {setIsOpenDeleteConfirm(false)}}
                />
              </>
            ))}
            <TableRow>
              <TableCell>
                <Select
                  value={comboItem}
                  onChange={handleChange}
                  displayEmpty
                > 
                  <MenuItem value={0}>支出</MenuItem>
                  <MenuItem value={1}>収入</MenuItem>
                </Select>
              </TableCell>
              <TableCell>
                <DateSelector
                  timestamp={timestamp}
                  setTimestamp={setTimestamp}
                />
              </TableCell>
              <TableCell>
                <Typography className={classes.textRed}>{errMsgText}</Typography>
                <TextField
                  value={houseText}
                  label="支出/収入の内容を入力"
                  variant="standard" 
                  size="small"
                  fullWidth
                  onChange={(e) => {setHouseText(e.target.value)}}
                />
              </TableCell>
              <TableCell>
              <Typography className={classes.textRed}>{errMsgAmount}</Typography>
              <TextField
                  value={houseAmount}
                  label="金額を入力"
                  variant="standard" 
                  className={classes.amount}
                  size="small"
                  fullWidth
                  onChange={(e) => {setHouseAmount(e.target.value)}}
                />
              </TableCell>
              <TableCell>
                <Button
                  variant="outlined"
                  onClick={() => addTask(comboItem, houseText, houseAmount, timestamp)}
                >
                  ＋
                </Button>
              </TableCell>
            </TableRow>
          </TableBody>
        </Table>
      </TableContainer>

      <LineChart width={700} height={300} data={chartMonthList} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
        <Line type='monotone' dataKey='expenditure' stroke='#8884d8' />
        <CartesianGrid stroke='#ccc' strokeDasharray='5 5' />
        <XAxis dataKey='month' />
        <YAxis />
        <Tooltip />
      </LineChart>

      <LineChart width={700} height={300} data={chartMonthList} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
        <Line type='monotone' dataKey='income' stroke='#8884d8' />
        <CartesianGrid stroke='#ccc' strokeDasharray='5 5' />
        <XAxis dataKey='month' />
        <YAxis />
        <Tooltip />
      </LineChart>
    </>
  );
}

export default HouseholdAccountbook;
```

```css:src/App.css
.App {
  text-align: center;
}

.App-logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .App-logo {
    animation: App-logo-spin infinite 20s linear;
  }
}

.App-header {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.App-link {
  color: #61dafb;
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
```

上記の準備フェーズで取得したAPIキー等の情報をここで入力する

```src/firebase.tsx 
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';

const firebaseConfig = {
  apiKey: "xxxxx",
  authDomain: "xxxxx",
  projectId: "xxxxx",
  storageBucket: "xxxxx",
  messagingSenderId: "xxxxx",
  appId: "xxxxx",
  measurementId: "xxxxx"
};

const app = initializeApp(firebaseConfig)

export const db = getFirestore();
export const storage = getStorage();

export default app;
```

# 実行

ターミナルを起動して、package.jsonのあるディレクトリで下記コマンドを実行する

```
npm run start
```


## アプリを動かしてみる

- 「支出 ot 収入」「年月日」「取引内容」「金額」を入力して「＋」ボタンを押下すると家計簿へ追加することができる
- 支出、収入それぞれを月単位で折れ線グラフ化する(折れ線グラフ 上:支出 下:収入)
- 「ゴミ箱」ボタンを押下すると家計簿から削除する


# 参考
- https://qiita.com/kashimuuuuu/items/0cc99820d120aae473fe
- https://btj0.com/blog/react/firebase-auth/
- https://reffect.co.jp/react/react-crud-firebase-9

不明点があればお気軽にメッセージやTwitterDMでご連絡ください