研究室向け入退室管理システムを作ってみた!React×Google Apps Scriptで実現

## はじめに
研究室に所属する学生なら、作成してみようと考える人も多い入退室管理システム。
研究室にシステムを導入することは有用ですが、制作は決して簡単ではありません。それでも、挑戦する価値は十分にあると感じています。私の作成した記録を残しました。
## どんなシステム？
入退室時にスイッチを押すことで、Slackに通知を送信
また、この情報を基に週単位での入室時間を管理し、表示します。
(週の入室時間が表示されるシステムは、やる気のない学生にとっては**悪魔的**)


今回は、プライバシーを考慮してアイコン画像に[spyfamily](https://spy-family.net/special/special13.php)を使用しました。
#### メイン画面
![main.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118679/3a3fed81-e519-8673-a4ba-06e330b59be1.png)

#### slackへの通知例
<img width="350" alt="ip.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118679/f5b785b0-f7b0-2dac-1d4d-eb6048b63d54.jpeg">


### 技術
**フロントエンド:React**
**バックエンド DB:Google Apps Script(GAS)**
スプレッドシート上で、ユーザー情報と入退室ログを保存するようにしています。

今回、あえてGASを利用したのは、プログラミング知識がない人でも簡単に編集が行える点を考慮したためです。
研究室はメンバーの入れ替わりが激しく、いちいちシステムの引き継ぎを行う必要がありますからね…
誰でも簡単に理解できるに越したことはありません！

### 機能
歯車マークからユーザーの編集・追加が行えます。
#### ユーザの追加画面
![wi1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118679/b5af11e3-166f-0336-a91a-66c80e795641.png)

編集時は、現在の名前を選択することで、現在の情報が表示されます。
#### ユーザの編集画面
![wi2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118679/6df2b482-22e2-e6db-d86f-8b829f1833c1.png)

### DB
#### ユーザー情報
ユーザー情報は以下のスプレッドシートで管理します。(カラムの説明は、システム設計を参照)
入室時、*intime*列に入室時間が入力され、退室時にその値と、退室時間からtimeが更新されます。
ページの読み込み・更新時に、このスプレッドシートを参照し表示を行います。

![sp1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118679/33488f12-c023-af33-a1dc-da234becca4b.png)

#### 入退室時間のログ
入退室時間は、こちらのシートにも入力し保存されます。
集団感染の恐れがある場合など、誰が研究室に訪れていたかわかるのは便利です。

![sp2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118679/b48f6a1c-6275-8dda-d97b-e2ab5d9fe284.png)


## システム設計
フロントエンドが直接GASエンドポイントを呼び出すクライアントサイドレンダリング（CSR)を用いました
**なぜ？**
ユーザーインタラクションに対する応答性を高めることため。
複数人が同時に入室・退出する場合や、入室時にスイッチを押し忘れた人が、退室時に一度入室ボタンを押してからすぐ、退室ボタンを押す場合でも、ユーザーの操作に対してページの一部分だけを更新することが可能になり、全ページの再ロードを防ぐ事ができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3118679/20b58f73-8582-e5ed-11c8-31bdd9705884.png)

### UIの作成
今回UIの作成にはVercelの提供している**V0**というサービスを用いました。
このサービスは、生成AIに対してテキストで指示を出すことで、自動的にWebページのUIを作成してくれるものです。修正も言語ベースで行えて便利です。細かなUIはV0を用い、大まかなレイアウトは自分で修正を行うのが個人的おすすめです。

https://v0.dev/t/ccZrI6MG26f

## 具体的なコード
重要だと思った箇所のみ！

### フロント側
```react
//型定義の重要箇所
interface DataToSend {
    flag: number;
    student_num: string;
    value?: string;
    slack_id?: string;
    name?: string;
    icon?: string;
  }

// GASにデータを送信する関数　mode: 'no-cors', // 本番環境でのCORSエラー回避には適切なCORS設定が必要
const sendDataToGAS = async (data:DataToSend) => {
  console.log("Sending to GAS:", data); // 送信データのログ出力

  const response = await fetch(GAS_WEB_APP_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'text/plain',
    },
    body: JSON.stringify(data),
    // CORSポリシーに対応するための追加の設定が必要な場合はここに記述
  });

  if (!response.ok) {
    throw new Error('Failed to send data to GAS');
  }

  // 応答からのデータを処理する場合
  const responseData = await response.json();
  console.log("Received from GAS:", responseData); // 受信データのログ出力
  console.log(responseData.message);
};
```
##
先週目標時間を達成しているか否かを`last_week === "achieved"`で判別しアイコンの枠の色を変更する事で視覚的にわかりやすく。 
```react
{/* 修士1年 */}
            <div className="grid gap-3 "style={{ display: 'flex', flexDirection: 'column' }}>
              <h1 className="text-xl font-semibold">修士1年</h1>
              {masterYear1.map(student => (
                <div className="grid grid-cols-2 items-center gap-4 pr-4" key={student.name}>
                  <div className="flex items-center space-x-4">
                  <Avatar className={`w-10 h-10 ${student.last_week === "achieved" ? "border-2 border-green-600" : "border-2 border-gray-400"}`}>
                      <AvatarImage alt={student.name} src={student.icon} />
                    </Avatar>
                    <div className="grid gap-1">
                      <div className="font-semibold">{student.name}</div>
                      <Workingtime time={student.time} />
                    </div>
                  </div>
                  <div className="flex items-center space-x-4 justify-self-end">
                    <a>In</a>
                    <Switch
                      id={`switch-${student.student_num}`}
                      checked={switchStates[student.student_num] ?? false}
                      onCheckedChange={() => handleSwitchChange(student)}
                    />
                    <a>Out</a>
                </div>
                  {/* 入室・退室ボタン */}
                </div>
              ))}
            </div>
```
##
週間の入室時間は棒グラフとして実現
時間に応じて、グラフの色を変更
```react
"use client"
import React from 'react';

interface WorkingtimeProps {
  time: number; // `time` プロパティが数値であることを明示
}

function Workingtime({time}: WorkingtimeProps) {
  const minValue = 0;
  const maxValue = 80;
  const initialValue = time;
  const chartWidth = 240;
  const chartHeight = 35;
  const barHeight = 20;
  const borderRadius = 5;
  const lineLength = 20; // 線の長さを調整
  const lineOffset = (chartHeight - lineLength) / 2; // 線の開始位置を中央に近づける

  const valueRatio = initialValue / maxValue;
  const barWidth = chartWidth * valueRatio;

  let fillColor = '#F54110';
  if (initialValue > 30 && initialValue < 60) {
    fillColor = '#0ff100';
  } else if (initialValue >= 60) {
    fillColor = '#0ff100';
  }

  return (
    <svg width={chartWidth} height={chartHeight}>
      <rect
        x="0"
        y={(chartHeight - barHeight) / 2}
        width={barWidth}
        height={barHeight}
        fill={fillColor}
        rx={borderRadius}
      />
      {[0, 30, 60].map((time) => {
        const xPosition = (time / maxValue) * chartWidth;
        const textOffset = time === 0 ? 5 : time === 60 ? -8 : 0; // 0と60のテキストの位置を調整
        return (
          <React.Fragment key={time}>
            <line x1={xPosition} y1={lineOffset} x2={xPosition} y2={lineOffset + lineLength} stroke="gray" />
            <text x={xPosition + textOffset} y={chartHeight - (barHeight / 2) - 5} fontSize="12" textAnchor="middle" fill="gray">{time}</text>
          </React.Fragment>
        );
      })}
    </svg>
  );
}

export default Workingtime;

```
##
### GAS

**doGet関数**
フロントの*sendDataToGAS*から送られてきたデータをもとに処理を行う関数
<details><summary>コード</summary>

```js
function doGet(e){
  Logger.log(e);
  if (e ==null){
    var flag = 0;

  }else{
    var flag = e.flag; //PCアプリ{1:入室 2：退室 3:追加 4:変更 5:更新}5の場合doGetの処理はない
    var student_num = e.student_num; //学籍番号；
    if (flag == 3 || flag == 4) {
      var value = e.value; //学年・学位
      var slack_id = e.slack_id//Slack{SlackのユーザーID}
      var name = e.name; //名前；
      var icon = e.icon; //slackのアイコン画像
    }
  }
  var result = {
      success: false,
      data: [],
      message: ""
    };

  //infoシートから投稿者のを検索し，行数を返す
  var ss = SpreadsheetApp.getActive(); //リンクしているスプレッドシートを取得
  var info_sheet = ss.getSheetByName("info"); //infoシートを指定
  var data = info_sheet.getRange(1, 1, info_sheet.getLastRow(), info_sheet.getLastColumn()).getValues();
  //Logger.log(data)
  var dataRange = info_sheet.getDataRange();
  //Logger.log(dataRange)
  var values = dataRange.getValues();
  //Logger.log(values)
  var Name;
  var Userid;
  var LastWeek;

  if (flag == 1 || flag == 2) { //入室か退室の場合
    var user_row = findRow(data, student_num); //infoシートの行数を取得
    Logger.log('user_row: '+user_row)
    Name = info_sheet.getRange(user_row, 2).getValue(); //表示名
    Logger.log('Name: '+Name)
    Userid = info_sheet.getRange(user_row, 4).getValue(); //アイコン取得のためのSlackユーザーID
    Logger.log('Userid: '+Userid)
    Icon = info_sheet.getRange(user_row, 6).getValue();
    LastWeek = info_sheet.getRange(user_row, 9).getValue();//先週の成果を取得
  }else if(flag == 4 || flag == 5){
    var user_row = findRow(data, student_num); //infoシートの行数を取得
  }

  //日付データから登録するセルを決定
  var now = new Date();
  var Month = now.getMonth() + 1;
  var Year = now.getFullYear();
  var Day = now.getDate();
  var Hour = now.getHours();
  var Min = now.getMinutes();
  var time = ("00" + Hour.toString()).slice(-2) + ":" + ("00" + Min.toString()).slice(-2);
  //Logger.log(Month)
  //Logger.log(Year)
  //Logger.log(Day)
  //Logger.log(Hour)
  //Logger.log(Min)

  if (flag == 1) {//入室の処理
    //決定されたセルに投稿内容を記録
    var sheet = set_sheet(Year, Month); //今月のシートを取得
    var user_row2 = 2*user_row+(user_row-2)-1;//infoの行数から実際の行数に変換
    sheet.getRange(user_row2 + 1, Day + 1).setValue(time); //入室時間を記録
    //同様に入室時間をinfoシートのintimeセルに記録
    //infoシートのstatusセルをinに変更
    // 学籍番号を基に該当する行を検索
    var studentRow = user_row; // 行番号は1始まり
    
    if (studentRow > 0) {
      // intime（入室時間）とstatus（ステータス）の更新
      info_sheet.getRange(studentRow, 7).setValue(now); // intime列
      info_sheet.getRange(studentRow, 8).setValue("in"); // status列
    } else {
      Logger.log("Student not found(1): " + student_num);
    }
    result.success = true;
    result.data = doPostAll();
    result.message = "Data fetched successfully_1";
  }
  if (flag == 2) {//退室の処理
    //決定されたセルに投稿内容を記録
    var sheet = set_sheet(Year, Month);
    var user_row2 = 2*user_row+(user_row-2)-1;//infoの行数から実際の行数に変換
    if (Hour < 4.0) { //午前4時までの退出は前日の退出として扱う
      sheet.getRange(user_row2 + 2, Day).setValue(time);
    } else { //一般的な退出
      sheet.getRange(user_row2 + 2, Day + 1).setValue(time);
    }
    //infoシートのintimeセルに記録された入室時間と今回の退室時間から在室時間を算出
    //在室時間をinfoシートのtimeセルに加算
    //infoシートのstatusセルをoutに変更
    // 学籍番号を基に該当する行を検索
    var studentRow = user_row; // 行番号は1始まり
    if (studentRow > 0) {
      var entryTime = new Date(info_sheet.getRange(studentRow, 7).getValue()); // intime列
      var exitDateTime = now;
      
      // 在室時間（時間）を算出
      var stayHours = (exitDateTime - entryTime) / (1000 * 60 * 60);
      stayHours = Math.floor(stayHours); // 小数点以下切り捨て
      
      // 既存の在室時間に加算
      var currentTime = parseInt(info_sheet.getRange(studentRow, 5).getValue()) || 0; // time列
      var newTime = currentTime + stayHours;
      if (isNaN(newTime)){
        Logger.log('newTime is NaN');
      }else{
      info_sheet.getRange(studentRow, 5).setValue(String(newTime)); // time列の更新
      Logger.log('Time'+newTime);  
      }
      // statusをoutに変更
      info_sheet.getRange(studentRow, 8).setValue("out"); // status列
      //intimeをリセット
      info_sheet.getRange(studentRow, 7).clearContent();
    } else {
      Logger.log("Student not found(2): " + student_num);
    }
    result.success = true;
    result.data = {student_num:student_num,intime:newTime};
    result.message = "Data fetched successfully_2";
  }else if (flag == 3){//追加時の処理
    var add_row = data.length + 1;

    //infoシートに情報追加
    info_sheet.getRange(add_row, 1).setValue(value)
    info_sheet.getRange(add_row, 2).setValue(name)
    info_sheet.getRange(add_row, 3).setValue(student_num)
    info_sheet.getRange(add_row, 4).setValue(slack_id)
    info_sheet.getRange(add_row, 5).setValue(null)
    info_sheet.getRange(add_row, 6).setValue(icon)
    info_sheet.getRange(add_row, 7).setValue(null)
    info_sheet.getRange(add_row, 8).setValue("out")
    info_sheet.getRange(add_row, 9).setValue('failed')
    

    //今月シートに名前追加
    var sheet = set_sheet(Year, Month);
    add_row = add_row + (add_row - 4) * 2
    sheet.getRange(add_row, 1).setValue(name)

    //originalシートに名前追加
    var ori_sheet = ss.getSheetByName("original");
    ori_sheet.getRange(add_row, 1).setValue(name)

    result.success = true;
    result.data = doPostAll();
    result.message = "Data fetched successfully_3";
    
  }else if (flag == 4){//変更時の処理
    info_sheet.getRange(user_row, 1).setValue(value)
    info_sheet.getRange(user_row, 2).setValue(name)
    info_sheet.getRange(user_row, 3).setValue(student_num)
    info_sheet.getRange(user_row, 4).setValue(slack_id)
    info_sheet.getRange(user_row, 6).setValue(icon)

    result.success = true;
    result.data = doPostAll();
    result.message = "Data fetched successfully_4";
  
  }else if (flag == 5){//削除時の処理
    info_sheet.deleteRow(user_row);
    result.success = true;
    result.data = doPostAll();
    result.message = "Data fetched successfully_5";
  
  }else{
    result.success = true;
    result.data = doPostAll();
    result.message = "Data fetched successfully_else";
  }
  Logger.log(Name);
  Logger.log(Userid);
  Logger.log(icon)
  Logger.log(flag);
  if (flag == 1 || flag == 2) {//アプリからの入力の場合Slackに通知
    send_to_slack(Name, Userid, flag,LastWeek);
  }

  // JSON形式の応答を作成
  var output = ContentService.createTextOutput(JSON.stringify(result))
    .setMimeType(ContentService.MimeType.JSON);

  return output;
}

//user_idの行数を検索
function findRow(data, id) {
  for (var i = 0; i < data.length; i++) {
    for (var j = 0; j < data[i].length; j++) {
      if (data[i][j] == id) {
        return i + 1;
      }
    }
  }
  return 0;
  
```
</details>

**send_to_slack関数**
入退室情報をslackの特定のチャンネルに送信する関数
先週の入室時間が目標時間に達していたら、ニコちゃんマークをつける☺
slackに送信を行う上で、slack上でアプリを作成する必要がある。その方法は以下を参照

https://zenn.dev/hotaka_noda/articles/4a6f0ccee73a18

<details><summary>コード</summary>

```js
//アプリからの入力の場合Slackに通知
function send_to_slack(Name, Userid, flag,LastWeek) {
  var message;
  if (flag == 1) {
    message = "🔹  入室しました";
    var fontcolor = "#2E64FE";
  } else if (flag == 2) {
    message = "🔺  退室しました";
    var fontcolor = "danger";
  }
  
  if (LastWeek == "achieved"){
    Logger.log('Yes')
    Name = Name + "☺";
  }

  let token = "トークンをここに記載";
  const url = "https://slack.com/api/users.info?user=" + Userid;

  // 投稿するチャンネルやメッセージ内容を入れる
  const option = {
    "token" : token,
    "username": Name,
    "icon_url": Icon,
    "attachments": [
      {
          "color": fontcolor,}]
  };
  //ライブラリから導入したSlackAppを定義し、トークンを設定する
  let slackApp = SlackApp.create(token);
  //Slackボットがメッセージを投稿するチャンネルを定義する
  let channelId = "チャンネルIDをここに記載";
 
  //SlackAppオブジェクトのpostMessageメソッドでボット投稿を行う
  slackApp.postMessage(channelId, message,option);
  return 1;
}
```
</details>

## 最後に
今回の経験が、同様のプロジェクトに取り組む人々の参考になれば幸いです。技術は日々進化していますが、重要なのはその技術をどのように活用して実生活の課題を解決するかです。私自身も新しい技術を学び続け、さまざまな挑戦をしていきたいと思います。