3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-03-10

背景

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

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

開発するアプリについて

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

スクリーンショット 2022-03-10 11.57.35.png

スクリーンショット 2022-03-10 11.57.37.png

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

  • フロントエンド

    • React.js
    • TypeScript
  • バックエンド

    • Firebase
    • Node.js

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

開発手順

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

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

準備: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

準備: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

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

スクリーンショット 2022-03-08 23.28.44.png

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

スクリーンショット 2022-03-09 0.08.13.png

スクリーンショット 2022-03-09 12.25.54.png

実装

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

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

スクリーンショット 2022-03-10 10.10.09.png

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

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

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

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

参考

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

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?