【Next.js + TypeScript】ユーザーがセルを選択すると色が変わるテーブルをつくる

今回の記事では、Next.jsとTypeScriptを使ってユーザーがセルを選択すると色が変わるテーブルを作成する手順を解説します。

また、選択されたセルのデータをFirestoreに保存する処理も作成するので、それについても解説していきますね。

この記事で作成するユーザーがセルを選択すると色が変わるテーブルは、管理者ユーザーがスタッフのシフトの入力をする機能という想定で作成します。

縦軸が日付で横軸が時間帯になっているテーブル(シフト表)があって、そのテーブルのセルをユーザーが選択するとセルの色が変わる。

そして、いくつかセルを選択した状態で保存ボタンを押すと、選択していたセルの情報(日付と時間帯)がFirestoreに保存される。

このようなシフト入力機能を作りながら、セルを選択すると色が変わるテーブルの作り方を解説していきますね。

## セルを選択すると色が変わるテーブル

今回つくるものは具体的には以下の動画のようなコンポーネントです。
![画面収録_2023-07-13_18_06_22_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/a10f75fa-5f7f-e883-178d-eb6cd37d80f6.gif)
このコンポーネントはスタッフのシフト入力を行うために使うものと想定して作成しています。

どのスタッフが何日の何時〜何時に稼働が可能なのかをテーブルのセルを選択して入力し、保存ボタンが押されたときに入力されたシフトのデータがFirestoreに保存されるというものです。

## 使用技術

今回使う技術は以下のようになっています。

・Next.js 13.4.9
・Chakra UI 2.7.1
・Firebase 10.0.0
・Handsontable 13.0.0

テーブルのコンポーネント自体はHandsontableというライブラリを使います。デザインはChakra UIを使って整えます。

## 必要なライブラリのインストール

以下のコマンドをターミナルに入力し、必要なライブラリをインストールしてください。

Firebase
```
yarn add firebase @types/firebase
```

Chakra UI
```
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
```

Handsontable
```
yarn add handsontable
```

```
yarn add @handsontable/react
```

## Firebaseの設定と初期化

Firebaseの管理画面から新しいプロジェクトを作成して、Next.jsのプロジェクトにFirebaseを初期化するファイルを用意します。

Firebaseの設定と初期化の方法はこちらの記事で解説しているので、詳しく知りたい方は参考にしてみてください。

https://qiita.com/masakiwakabayashi/items/741998ed5b830d8f3707

今回は、Next.jsのルートディレクトリにfirebaseというディレクトリを作成し、その中にfirebase.tsというファイルを作成してFirebaseの初期化を行います。

## Firestoreのデータ構造

今回のスタッフのシフトデータ、シフトの日付や時間などのデータを保存するFirestoreのデータ構造は以下のようにしていきます。

・staff
・date
・time

というコレクションがあり、staffにはスタッフの名前、dateにはスタッフがシフトに入ることが可能な日付、timeにはスタッフがシフトに入ることが可能な時間帯が保存されている。

そして、ユーザーがシフトを入力するためのテーブルのコンポーネントから入力されたシフトの情報はStaffドキュメントのshiftフィールドに保存される。

という感じですね。

## Firestoreにデータを入力する

Firestoreの管理画面から、今回使う授業が行われる日と時間、講師の名前などのデータをFirestoreに作成します。

staff、date、timeというコレクションを作成し、それぞれに以下のようにドキュメントを作成します。

* staff：nameフィールドにスタッフの名前を入れたドキュメントを複数作成

* date：IDがyyyy-mm-dd形式の日付になったドキュメント(フィールドは何もなくていい)を複数作成

* time：nameフィールドに時間帯(例：9:00〜10:00)が入ったドキュメントを複数作成

※スタッフのドキュメントIDやtimeコレクションのドキュメントIDは任意のものでも構いませんが、dateコレクションは日付をIDにしています。そのためdateコレクションの日付のドキュメントは必ずyyyy-mm-ddという形式にしておいてください。例：2023-07-01

実際に作成したドキュメントは以下のような感じになります。

・staffコレクション
![スクリーンショット 2023-07-13 15.53.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/13b25343-6495-62f1-22b1-e8a13c9d8feb.png)
・dateコレクション
![スクリーンショット 2023-07-13 15.53.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/fd642484-018f-a3cd-f783-34392dd36fdb.png)
・timeコレクション
![スクリーンショット 2023-07-13 15.54.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/b93d51fc-b56d-e34f-6115-bece46b0145a.png)

## ページコンポーネントを作成

まずは、ページコンポーネントから作成していきます。プロジェクトのpagesディレクトリにsraff.tsxというファイルを作成します。

sraff.tsxの中身は以下のようにしておきます。

```pages/sraff.tsx
import React, { useState, useEffect } from "react";
import { db } from "../firebase/firebase";
import {
    Box,
    Button,
    Flex,
} from "@chakra-ui/react";
import { getDocs, collection } from 'firebase/firestore';

const Staff = () => {
    // staffのデータを格納するuseState
    const [ staff, setStaff ] = useState<any>([]);

    // Firestoreからスタッフのデータを取得する処理
    const getData = async () => {
        const querySnapshot = await getDocs(collection(db, 'staff'));
        const staffArray: any = [];
        querySnapshot.docs.map((doc)=>{
            staffArray.push({
                id: doc.id,
                name: doc.data().name,
            });
        });
        setStaff(staffArray);
    }

    // useEffectでgetDataを実行
    useEffect(()=>{
        getData();
    },[]);

    return (
        <div>
            <Box>
                <Flex>
                    <Box p={3} fontWeight={'bold'}>
                        名前
                    </Box>
                    <Box>
                    </Box>
                </Flex>
            </Box>
            {
                // map関数を使ってstaffのデータを表示
                staff.map((item: any)=>{
                    return (
                        <Box key={item.id}>
                            <Flex>
                                <Box p={3}>
                                    {item.name}
                                </Box>
                                <Box>
                                    <Button>シフト入力</Button>
                                </Box>
                            </Flex>
                        </Box>
                    )
                })
            }
        </div>
    );
}

export default Staff;
```

実際にこのページコンポーネントの画面を見てみると、以下のようになっています。
![スクリーンショット 2023-07-12 11.12.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/750d219d-4171-e1ad-a7a9-82e2abd7bfd8.png)
現時点では、Firestoreからスタッフの一覧データを取得して、それをmap関数を使って画面に表示するだけのシンプルなページになっていますね。

ここからはテーブルのコンポーネントを作成し、スタッフの名前の横にあるシフト入力のボタンを押すと、シフトを入力するためのセルを選択すると色が変わるテーブルのコンポーネントがモーダルとして表示されるようにしていきます。


## テーブルをつくるためのコンポーネントを作成

Next.jsのプロジェクトのルートディレクトリにcomponentsディレクトリを新しく作成し、その中にShiftInput.tsxというファイルを作成します。

このファイルの中にユーザーが選択すると色が変わるテーブルを作っていきます。

まずは、どのスタッフのシフト入力ボタンがクリックされたのかを判別するために、staff.tsxのページコンポーネントからシフト入力ボタンがクリックされたスタッフのIDを渡して、テーブル用のコンポーネントに表示させるところまで作成します。

ShiftInput.tsxの中身を以下のように変更してください。

```components/ShiftInput.tsx
import React, { useRef, useEffect, useState } from 'react';
import { HotTable } from '@handsontable/react';
import { registerAllModules } from 'handsontable/registry';
import { registerRenderer, textRenderer } from 'handsontable/renderers';
import 'handsontable/dist/handsontable.full.min.css';


const ShiftInput = (props: any) => {
    // props
    const { modalId } = props;

    return (
        <div>
            {modalId}
        </div>
    );
}

export default ShiftInput;
```
この後、ページコンポーネントを編集して、シフト入力が押されたスタッフのIDをモーダルIDとしてShiftInput.tsxに渡すようにしていきます。

次はページコンポーネントのファイルを編集していきますね。

先ほど作成したShiftInput.tsxのコンポーネントが、staff.tsxのシフト入力ボタンを押したときにモーダルとして表示されるように、staff.tsxの中身を変更します。

staff.tsxの内容を以下のように変更してください。

```pages/staff.tsx
import React, { useState, useEffect } from "react";
import { db } from "../firebase/firebase";
import {
    Box,
    Button,
    Flex,
    useDisclosure,
    Modal,
    ModalOverlay,
    ModalContent,
    ModalHeader,
    ModalFooter,
    ModalBody,
    ModalCloseButton,
} from "@chakra-ui/react";
import { getDocs, collection } from 'firebase/firestore';
// 追加
import dynamic from "next/dynamic";

// ShiftInputコンポーネントをインポートするための処理
const ShiftInput = dynamic(() => import("../components/ShiftInput"), { ssr: false });


const Staff = () => {
    // staffのデータを格納するuseState
    const [ staff, setStaff ] = useState<any>([]);
    // シフト入力のモーダル
    const { isOpen, onOpen, onClose } = useDisclosure();
    // どのスタッフのシフト入力のボタンが押されたのかを判別するためのuseState
    const [ modalId, setModalId ] = useState(0);

    // Firestoreからスタッフのデータを取得する処理
    const getData = async () => {
        const querySnapshot = await getDocs(collection(db, 'staff'));
        const staffArray: any = [];
        querySnapshot.docs.map((doc)=>{
            staffArray.push({
                id: doc.id,
                name: doc.data().name,
            });
        });
        setStaff(staffArray);
    }

    // useEffectでgetDataを実行
    useEffect(()=>{
        getData();
    },[]);

    return (
        <div>
            <Box>
                <Flex>
                    <Box p={3} fontWeight={'bold'}>
                        名前
                    </Box>
                    <Box>
                    </Box>
                </Flex>
            </Box>
            {
                // map関数を使ってstaffのデータを表示
                staff.map((item: any)=>{
                    return (
                        <Box key={item.id}>
                            <Flex>
                                <Box p={3}>
                                    {item.name}
                                </Box>
                                <Box>
                                    <Button onClick={()=>{
                                        setModalId(item.id);
                                        onOpen();
                                    }}>シフト入力</Button>
                                </Box>
                            </Flex>
                        </Box>
                    )
                })
            }
            {/* シフト入力のテーブルコンポーネントを開くためのモーダル */}
            <Box>
                <Modal onClose={onClose} isOpen={isOpen} size={'6xl'}>
                    <ModalOverlay />
                        <ModalContent>
                            <ModalHeader>シフト入力</ModalHeader>
                            <ModalCloseButton />
                            <ModalBody>
                                <ShiftInput
                                    modalId={modalId}
                                />
                            </ModalBody>
                        </ModalContent>
                </Modal>
            </Box>
        </div>
    );
}

export default Staff;
```

今回、staff.tsxにShiftInput.tsxのコンポーネントをインポートするのに、dynamicというものを使っています。

このやり方をしないと実際にHandsontableでテーブルを作成したコンポーネントをstaff.tsxにインポートしたときにエラーが出るため、こういったやり方をしています。

実際に、staff.tsxの画面にアクセスして、シフト入力のボタンを押してみると、該当するスタッフのIDがモーダルに表示されることが確認できます。
![スクリーンショット 2023-07-12 14.19.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/05b54800-af54-d40b-f1db-4551b110f41d.png)

## ShiftInput.tsxにテーブルを作成する

モーダルでコンポーネント自体を表示させるところまで完了したので、ここからは実際にテーブル本体のコンポーネントを作成する方法を解説していきますね。

作成したいテーブルは以下のようなものです。
![スクリーンショット 2023-07-12 15.39.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/f6baba64-3328-c74d-bfb3-d4a538498420.png)
縦軸に日付が入っていて、横軸に時間帯が入っています。

こちらの日付と時間帯のデータはFirestoreのdateコレクションとtimeコレクションに保存されているので、これらのデータを取得してテーブル本体を作成していきます。

まずは、簡単なテーブルを表示させるところから行います。

ShiftInput.tsxの中身を以下のように書き換えます。

```components/ShiftInput.tsx
import React, { useRef, useEffect, useState } from 'react';
import { HotTable } from '@handsontable/react';
import { registerAllModules } from 'handsontable/registry';
import { registerRenderer, textRenderer } from 'handsontable/renderers';
import 'handsontable/dist/handsontable.full.min.css';
import {
    Box,
} from "@chakra-ui/react";

// Handsontableを使うための記述
registerAllModules();

const ShiftInput = (props: any) => {
    // props
    const { modalId } = props;
    // Handsontableを使うための記述
    const hotRef = useRef<HotTable>(null);

    // テーブル本体に使うデータ
    const shiftTableData = [
        ['', '9:00〜10:00', '10:00〜11:00', '11:00〜12:00', '13:00〜14:00'],
        ['7月1日 (土)', '', '', '', ''],
        ['7月2日 (日)', '', '', '', ''],
        ['7月3日 (月)', '', '', '', ''],
        ['7月4日 (火)', '', '', '', ''],
        ['7月5日 (水)', '', '', '', ''],
        ['7月6日 (木)', '', '', '', ''],
        ['7月7日 (金)', '', '', '', ''],
        ['7月8日 (土)', '', '', '', ''],
        ['7月9日 (日)', '', '', '', ''],
        ['7月10日 (月)', '', '', '', ''],
        ['7月11日 (火)', '', '', '', ''],
    ];

    return (
        <Box>
            <Box>
                {modalId}
            </Box>
            <HotTable
                ref={hotRef}
                data={shiftTableData}
                width="auto"
                colWidths={100}
                rowHeights={23}
                rowHeaders={false}
                colHeaders={false}
                outsideClickDeselects={false}
                selectionMode="multiple"
                licenseKey="non-commercial-and-evaluation"
                readOnly
                cells={(row: number, column: number, prop: string | number)=>{
                    const cellProperties: any = {};
                    return cellProperties;
                }}
            />
        </Box>
    );
}

export default ShiftInput;
```

実際に画面を見てみると、上記のテーブルはこのようになっています。
![スクリーンショット 2023-07-12 14.34.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/5fbfb810-354c-0617-a317-46fe70338cd0.png)
現時点では、テーブルの縦軸と横軸の日付と時間帯はハードコーディングしていますが、この日付と時間帯をFirestoreから取得してテーブルが表示されるようにしていきます。

## Firestoreから日付と時間のデータを取得してテーブルを作成する

大まか流れとしては以下の手順で作成していきます。

・Firesoteから日付の一覧と時間帯の一覧を取得
・取得したデータをベースに2次元配列を作成する
・作成したデータをHotTableのタグにdataプロパティとして渡す

まず、Firestoreから取得した日付と時間帯の情報を格納するuseStateを定義しておきます。

```components/ShiftInput.tsx
// テーブル本体を構成する多次元配列を格納するためのuseState
const [shiftTableData, setShiftTableData] = useState<any[][]>([]);
```

ShiftInput.tsxでFiretoreから日付と時間帯のデータを取得して、そのデータを配列にする関数を作成します。

```components/ShiftInput.tsx
    // 日付の配列を作成する (シフト表本体の配列を作成するために使う)
    const getDateArray = async () => {
        // Firestoreからdateコレクションのドキュメントを取得する
        const dateRef = collection(db, 'date');
        const querySnapshot = await getDocs(dateRef);
        // dateコレクションのドキュメントIDが日付になっているため、形式を変更して配列に格納する
        const docsDateData = querySnapshot.docs.map((doc: any) => {
            // 日付から曜日を取得する
            const date = new Date(doc.id);
            const dayOfWeek = date.getDay();
            const dayOfWeekString = ['日', '月', '火', '水', '木', '金', '土'][dayOfWeek];
            // 年数を除いて月と日だけの形式に変更する
            const formattedDate = `${date.getMonth() + 1}月${date.getDate()}日`;
            // 日付と曜日を結合して返す
            return formattedDate + ' (' + dayOfWeekString + ')';
        });
        return docsDateData;
    };

    // 時間帯の配列を作成する (シフト表本体の配列を作成するために使う)
    const getTimeArray = async () => {
        // Firestoreからtimeコレクションのドキュメントを取得する
        const timeRef = collection(db, 'time');
        const timeQuerySnapshot = await getDocs(timeRef);
        // timeコレクションのドキュメントのnameフィールドの値を配列に格納する
        const timeDocsData = timeQuerySnapshot.docs.map((doc: any) => {
            return doc.data().name;
        });
        // その配列をリターンする
        return timeDocsData;
    }

```

日付のデータはFirestoreに保存されている形式のままだとユーザーから見てわかりにくいため、曜日も追加してわかりやすい形式に変更しています。

次は上記の関数で配列として取得した日付と時間のデータから、テーブル本体を構成するための多次元配列のデータを作成します。ShiftInput.tsxに以下のような関数を追加してください。

```components/ShiftInput.tsx
    // 2つの配列の値を行と列にして多次元配列を作成するための関数
    const createMatrix = (arrayA: any, arrayB: any) => {
        const numRows = arrayA.length + 1;
        const numCols = arrayB.length + 1;
        const result = new Array(numRows);
        // 空の2次元配列を作成
        for (let i = 0; i < numRows; i++) {
            result[i] = new Array(numCols).fill('');
        }
        // 1列目に配列Aのデータを配置
        for (let i = 1; i < numRows; i++) {
            result[i][0] = arrayA[i - 1];
        }
        // 1行目に配列Bのデータを配置
        for (let j = 1; j < numCols; j++) {
            result[0][j] = arrayB[j - 1];
        }
        return result;
    };

    // 1列目が日付で1行目が時間帯なっている多次元配列を作成する
    const createDataArray = async () => {
        const dateArray = await getDateArray();
        const timeArray = await getTimeArray();
        const matrix = createMatrix(dateArray, timeArray);
        setShiftTableData(matrix as any);
    }

    // 配列の作成を実行
    useEffect(()=>{
        createDataArray();
    },[hotRef]);
```

上記のように作成した配列をuseStateに格納したら、その配列をHotTableのタグにdataプロパティとして渡します。

```components/ShiftInput.tsx
    return (
        <Box>
            <Box>
                {modalId}
            </Box>
            <HotTable
                ref={hotRef}
                // dataプロパティにshiftTableDataを渡す
                data={shiftTableData}
                width="auto"
                colWidths={100}
                rowHeights={23}
                rowHeaders={false}
                colHeaders={false}
                outsideClickDeselects={false}
                selectionMode="multiple"
                licenseKey="non-commercial-and-evaluation"
                readOnly
                cells={(row: number, column: number, prop: string | number)=>{
                    const cellProperties: any = {};
                    return cellProperties;
                }}
            />
        </Box>
    );
```

これで以下のように縦軸が日付で横軸が時間帯になったテーブルをFirestoreからデータを取得して作成できるようになりました。
![スクリーンショット 2023-07-12 15.39.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/3cf46d19-1e8c-81c7-ac2e-8e5d1dc68650.png)
ここまでで、実際にユーザーがスタッフのシフト入力の際に使うテーブル本体ができました。

## 選択されたセルの色が変わるようにする

ではここから、このテーブルの中でユーザーが選択した部分の色を変更する処理を作成していきます。

・選択されたセルの色が変わる
・すでに選択されているセルが選択された場合は色が元に戻る

というのがここで実装したいことですね。

セルの色の変更はクラスを付けたり外したりすることで行うので、まずはCSSのファイルを作成します。

プロジェクトのstylesディレクトリにstaff.cssというファイルを作成して、中身を以下のようにしておきます。今回はセルの色は青色に変更するように実装していきます。

```styles/staff.css
.bg-change {
    background-color: blue !important;
    color: blue !important;
    font-weight: bold;
}
```

staff.cssを_app.tsxにインポートします。

```pages/_app.tsx
import type { AppProps } from 'next/app'
import { ChakraProvider } from '@chakra-ui/react'
// staff.cssを読み込む
import '../styles/staff.css';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ChakraProvider>
      <Component {...pageProps} />
    </ChakraProvider>
  )
}
```

これでCSSが反映されるようになるので、セルの要素にbg-changeというクラスが付いたときにセルの色が変わるようになります。

では、テーブルのセルが選択されたときに、選択されたセルにbg-changeというクラス名を付けたり、すでにbg-changeというクラス名がついたセルが選択されたときにはクラス名を外したりする処理を作成していきます。

ShiftInput.tsxに以下のような処理を追加してください。

```components/ShiftInput.tsx
    // 選択されたセルの色を変更するための処理
    const changeSelectedCellsColor = async () => {
        if (hotRef.current) {
            const hot = hotRef.current.hotInstance;
            if (hot !== null) {
                hot.addHook('afterSelectionEnd', (row1, col1, row2, col2) => {
                const startRow = Math.min(row1, row2);
                const endRow = Math.max(row1, row2);
                const startCol = Math.min(col1, col2);
                const endCol = Math.max(col1, col2);
                hot.suspendRender();
                for (let rowIndex = startRow; rowIndex <= endRow; rowIndex++) {
                    for (let columnIndex = startCol; columnIndex <= endCol; columnIndex++) {
                        const currentData = hot.getDataAtCell(rowIndex, columnIndex);
                        const currentClassName = hot.getCellMeta(rowIndex, columnIndex).className;
                        // 1列目と1行目のセルは変更できないようにする
                        if (rowIndex !== 0 && columnIndex !== 0) {
                            // セルの値を変化させないと背景色が変化しないため、セルの値も変化させる
                            // 選択されたときのセルの値は-としているが、CSSで文字の色と背景色を同じにしているのでユーザーには見えない
                            if (currentData === '-' && currentClassName === 'bg-change') {
                                // すでに選択されているセルが再度選択された場合は元に戻す
                                hot.setDataAtCell(rowIndex, columnIndex, ''); // 元のデータに戻す
                                hot.setCellMeta(rowIndex, columnIndex, 'className', ''); // 背景色を元に戻す
                            } else {
                                hot.setDataAtCell(rowIndex, columnIndex, '-');
                                hot.setCellMeta(rowIndex, columnIndex, 'className', 'bg-change');
                            }
                        }
                    }
                }
                hot.resumeRender();
                });
            }
        }
    }

    // 選択されたセルの色を変更するための処理を実行
    useEffect(() => {
        changeSelectedCellsColor();
    }, [hotRef]);
```

ここまでやって、実際に画面を開いてシフト入力のボタンを押して、シフト入力のモーダルを開いてみると、テーブルが選択されたセルの色が変わるようになっていることが確認できると思います。
![スクリーンショット 2023-07-12 16.20.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/d3c683ef-4ebb-51ae-8865-5a542087f910.png)
ちなみに、シフト入力のテーブルの1行目と1列目のセルはFirestoreから取得したデータが入っているので、選択しても色が変わらないようにしています。

また、クラス名を付け替えるだけでは選択されているシフト情報をFirestoreに保存するのが難しいため、セルが選択されたときにセルを青い色に変更するだけではなくセルの中に-を入れています。

選択されたセルに入れる-も青色になっているのでユーザーには見えないようになっています。

## 選択されたシフトデータをFirestoreに保存できるようにする

では、実際に選択されたシフト入力テーブルのセルの色を変更するところまでできたので、次は選択されたシフト情報をFiresotreに保存する機能を作成していきましょう。

実装の方針としては、先ほど作成したシフト入力のテーブルの下にボタンを設置して、そのボタンが押されたときにFirestoreに選択されているセルのシフト情報が保存されるという感じにしていきます。

まずは、シフトの情報をFirestoreに保存する関数を作成します。ShiftInput.tsxに以下の関数を追加してください。

```components/ShiftInput.tsx
    // 日付のリファレンスの配列を作成する (Firestoreに講師のシフト情報を保存するときに使う)
    const getClassDateRangeRefArray = async () => {
        const dateRangeRef = collection(db, 'date');
        const querySnapshot = await getDocs(dateRangeRef);
        const docsDateData = querySnapshot.docs.map((doc: any) => {
            return doc.ref.path;
        });
        return docsDateData;
    }


    // 時間帯のリファレンスの配列を作成する (Firestoreに講師のシフト情報を保存するときに使う)
    const getClassTimeRefArray = async () => {
        const timeRef = collection(db, 'time');
        const timeQuerySnapshot = await getDocs(timeRef);
        const timeDocsData = timeQuerySnapshot.docs.map((doc: any) => {
            return doc.ref.path;
        });
        return timeDocsData;
    }

    // シフトデータのセル座標を講習期間と時限のリファレンスに変更する
    const convertShiftDataToReferencedFormat = async (
        ShiftArray: any,
        dateRefArray: any,
        timeRefArray: any
    ) => {
        const referencedFormatShiftArray: any = [];
        ShiftArray.map((doc: any)=>{
            referencedFormatShiftArray.push({
                id: `${doc.row as number}_${doc.col as number}`,
                time : timeRefArray[doc.col as number - 1],
                date : dateRefArray[doc.row as number - 1],
            });
        });
        return referencedFormatShiftArray;
    }

    // シフトデータを登録する前に既存のシフトデータを削除する処理 (これをやらないと選択を解除したシフトが消えない)
    const beforeShiftDataDelete = async (staffId: any) => {
        // ドキュメントがすでに存在するかどうかを判別する
        const docRef = doc(db, "staff", staffId);
        const docSnap = await getDoc(docRef);
        // ドキュメントが存在していた場合はシフトデータを削除する (ドキュメントが存在していない場合にこれをやるとエラーになる)
        if (docSnap.exists()) {
            await updateDoc(doc(db, "staff", staffId), {
                shift : deleteField(),
            });
        }
    }

    // 実際にシフトデータを登録する処理
    const storeShiftData = async (staffId: any, ShiftArray: any) => {
        for (let i = 0; i < ShiftArray.length; i++) {
            // 文字列になっているパスからドキュメントのリファレンスを取得する
            const timeRefPath = ShiftArray[i].time;
            const dateRefPath = ShiftArray[i].date;
            const timeRefPathSegments = timeRefPath.split('/');
            const dateRefPathSegments = dateRefPath.split('/');
            // リファレンスのパスからIDを取得して、そのIDからドキュメントのリファレンスを取得する
            const timeRef = doc(db, "time", timeRefPathSegments[1]);
            const dateRef = doc(db, "date", dateRefPathSegments[1]);
            // updateDocを使うとfor文の繰り返しごとに1つのフィールドにシフトが上書きされるのでsetDocとmergeオプションを使う
            await setDoc(doc(db, "staff", staffId), {
                shift : {
                    [ShiftArray[i].id] : {
                        time : timeRef,
                        date : dateRef,
                    },
                }
            }, { merge: true });
        }
    }

    // Firestoreにスタッフのシフトを保存するための関数
    const saveShift = async (ShiftArray: any) => {
        // 日付と時間帯のリファレンスを配列として取得
        const dateRefPathArray = await getClassDateRangeRefArray();
        const timeRefPathArray = await getClassTimeRefArray();
        // セルの座標を日付と時間帯のリファレンスに変更する
        const referencedFormatShiftArray =
            await convertShiftDataToReferencedFormat(ShiftArray, dateRefPathArray, timeRefPathArray);
        // 選択が解除されたセルのシフト情報を削除するために一旦Shiftフィールドを消してから再度登録する
        await beforeShiftDataDelete(modalId);
        // リファレンス形式になったシフト情報を保存する
        await storeShiftData(modalId, referencedFormatShiftArray).then(()=>{
            console.log("Document successfully updated!");
        });
    }

    // 選択されて色が変わっているセルの情報を取得してFirestoreに保存する
    const saveSelectedCellsToFirestore = () => {
        const selectedCells = [];
        if (hotRef.current) {
            const hotInstance = hotRef.current.hotInstance;
            if (hotInstance) {
                const rowCount = hotInstance.countRows();
                const colCount = hotInstance.countCols();
                for (let row = 0; row < rowCount; row++) {
                    for (let col = 0; col < colCount; col++) {
                        const cellValue = hotInstance.getDataAtCell(row, col);
                        // 選択されているセルは値が-になっているかどうかで見分ける
                        if (cellValue === '-') {
                            selectedCells.push({ row, col });
                        }
                    }
                }
            }
        }
        saveShift(selectedCells);
    };
```

シフトの情報は、Firestoreのsftaffコレクションのドキュメントのshiftフィールドに、dataコレクションとtimeコレクションのリファレンスとして保存していきます。

シフト保存ボタンが押されると、まずはsaveSelectedCellsToFirestoreという関数が実行されます。

この関数で選択されて色が青に変わっているセル(値が空白ではなく-になっているセル)の座標を取得します。ここで取得したセルの座標はsaveShiftという関数に渡されます。

saveShift関数では、まずFirestoreから日付と時間帯のリファレンスのパスを取得します。これはシフト情報を保存するときに日付と時間帯のリファレンスとして保存するためですね。

次にconvertShiftDataToReferencedFormatという関数が実行され、先ほどsaveShift関数に渡されたセルの座標が日付と時間帯のリファレンスのパスに変換されます。

このconvertShiftDataToReferencedFormatはどういったことをしているのかというと、シフト入力のテーブルの座標がどの日付と時間帯のセルなのかを判別し、先ほど取得しておいた日付と時間のリファレンスのパスに変換する(変換するというよりは新しい別の配列を作成して、それをリターンする)といったことをしています。

また、一度シフトを保存して、保存した部分のセルの選択を解除して再度シフト登録のボタンを押したときに、選択が解除された部分のシフトがFirestoreに残ってしまわないように、シフトを登録する直前にスタッフのドキュメントのshiftフィールドを削除するようにしています。これをやっているのがbeforeShiftDataDeleteという関数です。

そして、storeTeacherShiftDataという関数が実際にFirestoreにシフト情報を保存する関数になっています。

このstoreTeacherShiftData関数は、日付と時間帯のリファレンスのパスをリファレンスに変換して、Firestoreに保存する関数ですね。

ちなみに、この関数の中でFirestoreにシフト情報を保存するのにupdateDocではなく、setDocを使っているのはfor文の繰り返しが行われるごとにシフトの情報が上書きされてしまうのを防ぐためです。

次はシフト保存ボタンの設置です。

そして、シフト入力のコンポーネントにシフト保存ボタンを設置し、このボタンが押されたときにその関数が実行されるように実装します。

```components/ShiftInput.tsx
    return (
        <Box>
            <Box>
                {modalId}
            </Box>
            <HotTable
                ref={hotRef}
                data={shiftTableData}
                width="auto"
                colWidths={100}
                rowHeights={23}
                rowHeaders={false}
                colHeaders={false}
                outsideClickDeselects={false}
                selectionMode="multiple"
                licenseKey="non-commercial-and-evaluation"
                readOnly
                cells={(row: number, column: number, prop: string | number)=>{
                    const cellProperties: any = {};
                    return cellProperties;
                }}
            />
            {/* シフトを保存する関数を実行するためのボタンを設置 */}
            <Box>
                <Button onClick={saveSelectedCellsToFirestore} m={4}>シフトを保存する</Button>
            </Box>
        </Box>
    );
```

これで、実際にシフト入力のテーブルのセルを選択してシフト保存ボタンを押すと、Firestoerにシフトデータが保存されているのがわかると思います。
![スクリーンショット 2023-07-12 18.04.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/8c13bdae-a970-8a75-d679-8a7e4faab5a8.png)

## すでにFirestoreに保存されているシフトデータがテーブルコンポーネントに反映された状態にする

今の状態のままだとシフトを保存して、再度同じスタッフのシフト入力画面を開いても、シフト表にすでに登録されているシフトは反映されません。

なので、すでにFirestoreに保存されたシフトの情報が、シフト入力のモーダルを開いたときにテーブルに反映されているようにしていきたいと思います。

まずは、Firestoreから取得する既に登録されているシフトの情報を格納するuseStateを定義します。

```components/ShiftInput.tsx
// シフトの初期値を反映するための処理
const [ shiftDefaultData, setShiftDefaultData ] = useState<any>([]);
```

Firestoreから取得した情報は上記のuseStateに格納し、その値をテーブルのセルに反映することで、シフト入力のモーダルを開いたときに既に登録されているシフト情報がテーブルに反映されている状態にしていきます。

ShiftInput.tsxに以下のような関数を追加します。

```components/ShiftInput.tsx
    // 日付の配列を作成する (すでに登録されているシフトデータを反映するために使う)
    const getClassDateRangeArrayForDefault = async () => {
        const dateRef = collection(db, 'date');
        const querySnapshot = await getDocs(dateRef);
        const docsDateData = querySnapshot.docs.map((doc)=>{
            return doc.id;
        });
        return docsDateData;
    }

    // すでに登録されているシフトデータを反映するための2次元配列を作成する関数
    const createShiftDefaultDataMatrix = async (staffData: any) =>{
        const shiftData: any = [];
        if (staffData && staffData.shift) {
            // シフトを構成するデータを取得する (Firestoreから取得したシフトデータの座標を調べるため)
            const classDateRangeArray = await getClassDateRangeArrayForDefault();
            const classTimeArray = await getTimeArray();
            // awaitを使うためにfor文を使ってループさせる
            for (const value of Object.values<any>(staffData.shift)) {
                // リファレンスからドキュメントのデータを取得
                const timeRef = doc(db, 'time', value.time.id);
                const timeDoc = await getDoc(timeRef);
                const timeData = timeDoc.data();
                // 時限と講習期間がシフトを構成する配列の何番目なのかを取得
                const dateIndex = classDateRangeArray.indexOf(value.date.id) + 1;
                const timeIndex = classTimeArray.indexOf(timeData?.name) + 1;
                // シフト表を構成するデータを2次元配列に追加
                shiftData.push([dateIndex, timeIndex]);
            }
        }
        return shiftData;
    }

    // すでに登録されているシフト情報の取得
    const getShiftData = async () => {
        // 特定のスタッフのデータをFirestoreから取得
        const Ref = doc(db, 'staff', modalId);
        const querySnapshot = await getDoc(Ref);
        const staffData = querySnapshot.data();
        // 取得したデータからシフトデータを反映するための2次元配列を作成する
        const defaultShiftData = await createShiftDefaultDataMatrix(staffData);
        // シフトデータをuseStateにセット
        setShiftDefaultData(defaultShiftData);
    }

    // すでに登録されているシフト情報の取得を実行
    useEffect(()=>{
        getShiftData();
    },[modalId]);


    // 上記の関数で取得した、すでに登録されているシフトデータをテーブルに反映するための処理
    useEffect(()=>{
        if (hotRef.current) {
        const hot = hotRef.current.hotInstance;
        if (hot == null) return;
        shiftDefaultData.map(([x,y]: [number, number])=>{
            hot.setDataAtCell(x, y, "-");
            hot.setCellMeta(x, y, 'className', 'bg-change-2');
        });
        }
    },[shiftDefaultData]);
```

登録されているシフトの情報を取得するgetShiftData関数は、modalIdの値が変わったとき、つまりページコンポーネントにあるスタッフのシフト入力ボタンが押されたときに実行されるようになっています。

シフト表にデータを反映させるには、Firestoreに保存されているリファレンスの形式ではなく、セルの座標の形式に変換する必要があります。

その変換を行なっているのが、createShiftDefaultDataMatrix関数ですね。

まずgetShiftData関数内でスタッフのデータを取得して、そのスタッフのデータをcreateShiftDefaultDataMatrix関数に渡します。

そして、この関数内で日付と時間のリファレンスをシフト表のテーブルのセル座標に変換します。

具体的にどのようにそれをやっているのかというと、まずシフト表本体を構成する日付と時間のデータを配列として取得します。そして、この関数に渡されたスタッフのデータの中にあるシフトの情報が、配列の何番目なのかを日付と時間帯それぞれで取得します。その配列の何番目なのかという数値に1を足してを2次元の配列にすればセルの座標になります。

1を足すのはシフト表では日付も時間帯も2行目と2列目からスタートしているからですね。

こうして色をつけるべきテーブルのセルの座標が判明したので、そのセルの値を-にして、bg-change-2というクラス名を付けていきます。

次に、staff.cssのファイルに以下の記述を追加します。

```styles/staff.css
.bg-change-2 {
    background-color: blue !important;
    color: blue !important;
    font-weight: bold;
}
```

これですでにFirestoreに登録されているシフトデータが存在していた場合、シフト入力のテーブルにそのシフト情報が反映された状態になります。
![画面収録_2023-07-13_18_25_48_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3089293/c21ea943-7ede-0354-aaad-b32982870b8f.gif)

## まとめ

Handsontableというライブラリを使うことで、ユーザーがセルを選択したときに色が変わるテーブルを作成することができます。

また、Firestoreも使うことでユーザーがどのセルを選択したのかを保存する機能も作ることができました。

今回実装したシフト入力のテーブルコンポーネントのgithubのリポジトリはこちらです。

https://github.com/masakiwakabayashi/next_shift_input_table