Next.jsでスプレッドシートをDB代わりに使ってみる

スプレッドシートをDB代わりに活用してみたいと思い、スプレッドシートとNext.jsを連携したサイトを作ってみました。
まだまだNext.jsのバージョン15に関する資料が少ないと感じているので、少しでも参考になれば。
個人開発していると、規模的にSupabaseとか使うまででもないんだよなー、スプレッドシートでいいんだよなーという場面があったのと、どうしてみんなしないんだろう？という疑問があったので自己学習兼ねてやってみました。

## 利用用途
- GASで取得してスプレッドシートに入れているデータをローカルサーバーから確認する
- 顧客管理システムの作成にもつながる

## 完成形
### UI
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524273/0cf21bfe-56e4-191f-301d-74efc84f0ff6.png)

### 日付の選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524273/9b0795ef-08a4-581f-e475-a224cd867a52.png)

### 日付選択後のイメージ
![貼り付けた画像_2025_02_10_0_27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524273/0d95d25a-a42d-c610-e906-824e82394ef9.png)

### スプレッドシート側のイメージ
![貼り付けた画像_2025_02_10_0_26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/524273/c370b2c9-85d3-ae0d-9002-346dc2b6f7e7.png)


## 使用技術
- Next.js: 15.1.6
- Typescript
- Google apps script

## 開発の実際
### Next.jsの立ち上げ
`npx create-next-app@latest`でプロジェクト立ち上げましょう。基本はRecommendの通り選択して大丈夫です。

### 簡単なUIの作成
スプレッドシート側のデータを受けて出力させるページを用意します。今回はshadcn/uiを利用しています。利用したコンポーネント`Tabs, Caledar, Card, Button, Popover`になります。

Hooksを使わないシンプルなものをこの段階では用意しました。
```typescript:app>page.tsx
import { useEffect, useState } from "react";
import {  Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Calendar } from "@/components/ui/calendar";
import * as React from "react"
import { format } from "date-fns"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

export default function Home() {
  return (
    <div className="">
      <h1 className="text-4xl font-bold text-center py-10">CSRで取得する実績一覧</h1>
      <Popover>
        <PopoverTrigger asChild>
          <Button
            variant={"outline"}
          >
            <span>Pick a date</span>
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-auto p-0" align="start">
          <Calendar
            mode="single"
            selected={date}
            //onSelect={(selectedDate: Date | undefined) => setDate(selectedDate)}
            initialFocus
          />
        </PopoverContent>
      </Popover>

      {/* タブグループで分ける */}
      <Tabs defaultValue="account" className="w-[1080px] mx-auto mt-12">
        <TabsList className="grid w-full grid-cols-2">
          <TabsTrigger value="account">施設</TabsTrigger>
          <TabsTrigger value="password">店舗</TabsTrigger>
        </TabsList>

        <TabsContent value="account">
          <Card>
            <CardHeader>

            </CardHeader>
            <CardContent className="space-y-2">
            
            </CardContent>
          </Card>
        </TabsContent>

        <TabsContent value="password">
          <Card>
            <CardHeader>
        
            <CardContent className="space-y-2">
              
            </CardContent>
          </Card>
        </TabsContent>
      </Tabs>
    </div>
  );
}

```
### スプレッドシート側の準備
施設データというシートに対して、ヘッダー行があってそこからデータを取得してくることを考えています。
GAS（Typescript）で組んでウェブアプリにして最終的にNext.js側でFetchするようにします。

```typescript:Google apps scriptで作るmain.ts
const fileId = PropertiesService.getScriptProperties().getProperty("THIS_FILE_ID");
const clinicSheetName = "施設データ";
const shopSheetName = "店舗データ";

function doGet(e: any){
    const dateParam = e.parameter.date;
    if(!dateParam){
        return ContentService.createTextOutput(JSON.stringify({error: "日付指定なし"})).setMimeType(ContentService.MimeType.JSON);
    }
    if(!fileId) {
        return ContentService.createTextOutput(JSON.stringify({error: "ファイルが見つかりませんでした"})).setMimeType(ContentService.MimeType.JSON);
    }
    const ss = SpreadsheetApp.openById(fileId);
    const clinicData = ss.getSheetByName(clinicSheetName)?.getDataRange().getDisplayValues()!;
    const clinicHeaders: string[] = clinicData[0];
    const clinicResult = [];

    for (let i = 0; i < clinicData.length; i++){
        const row: string[] = clinicData[i];
        const rowData: {[key: string]: string} = {};

        for (let j = 0; j < clinicHeaders.length; j++){
            rowData[clinicHeaders[j]] = row[j];
        }

        if(rowData["日付"] === dateParam){
            clinicResult.push(rowData)
        }
    }
    return ContentService.createTextOutput(JSON.stringify({
        date: dateParam,
        records: clinicResult
    })).setMimeType(ContentService.MimeType.JSON);
}
```
取得したデータをJSON形式にして返します。
デプロイ＞ウェブアプリにしてできたURLをコピーしておきます。

### Next.js側でのFetch処理
必要となるのは、日付情報としての`[date, setDate]`と得られたデータの加工のための`[data, setData]`になります。
ここで自分の学びになったのは、状態管理のuseSateを使う時には初期値の設定だけじゃなくてしっかり型定義しておくことが必要ということでした。そりゃそうだよね。

最終的なコード
```typescript:app>page.tsx
"use client";

import { useEffect, useState } from "react";
import {  Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Calendar } from "@/components/ui/calendar";
import * as React from "react"
import { format } from "date-fns"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

// データの型定義
interface Records {
  日付: string,
  施設名: string,
  担当名: string,
  セル値: string,
  メニュー: string,
  施設売上: string,
}

interface ApiResponse {
  records: Records[]
}

export default function Home() {
  const [data, setData] = useState<Records[]>([]);
  const [date, setDate] = useState<Date | undefined>(new Date())

  const webAppUrl: string = process.env.NEXT_PUBLIC_WEBAPP_URL!;
  console.log("webAppUrl");
  useEffect(() => {
    if(!date) return;
    
    const formattedDate = format(date, "yyyy年MM月dd日");
    console.log(`${webAppUrl}?date=${formattedDate}`);
    fetch(`${webAppUrl}?date=${formattedDate}`)
      .then((res) => res.json())
      .then((json) => {
        if(json.records){
          setData(json.records)
        } else {
          setData([])
        }
      })
      .catch((err) => console.error("データ取得エラー:", err))
  }, [date])

  return (
    <div className="">
      <h1 className="text-4xl font-bold text-center py-10">CSRで取得する実績一覧</h1>
      <Popover>
        <PopoverTrigger asChild>
          <Button
            variant={"outline"}
            className={cn(
              "w-[480px] justify-center text-center font-normal flex mx-auto",
              !date && "text-muted-foreground"
            )}
          >
            {date ? format(date, "yyyy年MM月dd日") : <span>Pick a date</span>}
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-auto p-0" align="start">
          <Calendar
            mode="single"
            selected={date}
            onSelect={(selectedDate: Date | undefined) => setDate(selectedDate)}
            initialFocus
          />
        </PopoverContent>
      </Popover>

      {/* タブグループで分ける */}
      <Tabs defaultValue="account" className="w-[1080px] mx-auto mt-12">
        <TabsList className="grid w-full grid-cols-2">
          <TabsTrigger value="account">施設</TabsTrigger>
          <TabsTrigger value="password">店舗</TabsTrigger>
        </TabsList>

        <TabsContent value="account">
          <Card>
            <CardHeader>
              <CardTitle>{date ? format(date, "yyyy年MM月dd日の施設実績") : <span>日時を選択してください</span>}</CardTitle>
            </CardHeader>
            <CardContent className="space-y-2">
            {data.length > 0 ? (
              <table className="w-full">
                <tr className="font-bold">
                  <td>施設名</td>
                  <td>担当名</td>
                  <td>メニュー</td>
                  <td>施設売上</td>
                </tr>
                {data.map((item, index) => (
                  <>
                    <tr key={index}>
                      <td>{item["施設名"]}</td>
                      <td>{item["担当名"]}</td>
                      <td>{item["メニュー"]}</td>
                      <td>{item["施設売上"]}</td>
                    </tr>
                  </>
                ))}
              </table>
            ) : (
              <p>該当データがありません</p>
            )}
            </CardContent>
          </Card>
        </TabsContent>

        <TabsContent value="password">
          <Card>
            <CardHeader>
              <CardTitle>{date ? format(date, "yyyy年MM月dd日") : <span>未選択</span>}の店舗実績</CardTitle>
            </CardHeader>

            <CardContent className="space-y-2">
              
            </CardContent>
          </Card>
        </TabsContent>
      </Tabs>
    </div>
  );
}

```

これでカレンダーを選択するとその日付情報に応じたデータが出力されるようになります。

## やってみた感想
実際にデータが取得できたのは感動しました。おおー連携できた！みたいな。
でも何回かやっていると、処理の遅さが気になる。CSRで都度とっているのと、GAS側の処理のスピードが絡んでいるんだと思う。
だから先人たちはSupabaseとかFirebaseとかの利用を促すのねーと感心。
でもデータテーブル作ってマイグレーションして、Prismaいれて…という一連の流れを考えたら今回の実装の方が簡単ではあった（と思う）。

## 今後やりたいこと
今回は施設データと店舗データという2種のデータを用意しているので、タブの切り替えでこれができるようにしたい。