🙆

面談記録管理アプリ開発:面談記録詳細閲覧機能と新規登録機能の実装

2024/12/23に公開

はじめに

今回は面談記録の詳細閲覧と新規登録機能の実装を解説して行きます。

前回の記事はこちら↓
https://zenn.dev/kenberu1200/articles/975e5af1f97b3e

面談記録の詳細閲覧機能を実装

面談記録の詳細画面を実装していきます。

Show.jsxの作成

詳細画面のフロントエンド部分を実装します。

完成したページはこんな感じです。

まず、resources/js/Pages/MeetingLogディレクトリ内に、Show.jsxファイルを作成します。

作成したShow.jsxを以下のように記述して、詳細画面を実装します。
https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Pages/MeetingLog/Show.jsx

チャット機能の解説は次回以降で行おうと思うので、ここではreturn部分の解説のみ行います!

return部分は、ナビゲーションバー・基本情報・面談記録・チャット欄にパーツが別れています。

  • ナビゲーションバー
    他ページ同様AuthenticatedLayoutを用いてナビゲーションバーを表示しています。
<AuthenticatedLayout
    user={auth.user}
    header={
    <div className="flex justify-between items-center">
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
            {`面談記録 - "${meetingLog.title}"`}
        </h2>
        {meetingLog.user.office.id == auth.user.office.id || auth.user.is_global_admin?(
        <Link
            href={route("meetinglog.edit", meetingLog.id)}
            className="bg-emerald-400 py-1 px-3 text-gray-900 rounded shadown transition-all hover:bg-emerald-500"
        >
            編集
        </Link>
        ): ""}

    </div>
    }
>

header部分には、h2タグ内に面談記録のタイトルを表示しています。

さらに、面談記録作成者と同じ事業所に所属するアカウントか最上位権限を持っている人のみ編集ボタンを表示させるようにしています。

  • 基本情報の表示部分
<div className="grid gap-1 grid-cols-2">
    <div>
        <div>
            <label className="font-bold text-lg">ID</label>
            <p className="mt-1">{meetingLog.id}</p>
        </div>
        <div className="mt-4">
            <label className="font-bold text-lg">利用者名</label>
            <p className="mt-1">{meetingLog.member.name}</p>
        </div>
        <div className="mt-4">
            <label className="font-bold text-lg">体調</label>
            <p className="mt-1">{meetingLog.condition}</p>
        </div>
    </div>
    <div>
        <div>
            <label className="font-bold text-lg">作成者</label>
            <p className="mt-1">{meetingLog.user.name}</p>
        </div>
        <div className="mt-4">
            <label className="font-bold text-lg">事業所</label>
            <p className="mt-1">{meetingLog.member.office.name}</p>
        </div>
        <div className="mt-4">
            <label className="font-bold text-lg">作成日</label>
            <p className="mt-1">{meetingLog.created_at}</p>
        </div>
    </div>
</div>

grid gird-cols-2を指定することで、そのタグ中の要素を2分割して表示しています。

その中でさらに表示させたい要素を2つのグループに分けて、divタグで囲っています。

さらに各々の表示したい情報をlabelタグとpタグで表現して、それをdivタグで囲うことで、ページを表現しています。

  • 面談記録とチャット欄
<div className="grid gap-1 grid-cols-2">
    <div>
        <div>
            <label className="font-bold text-lg">面談記録</label>
            <div className="mt-1 whitespace-pre-wrap max-h-[400px] overflow-y-auto">
                {meetingLog.meeting_log}
            </div>
        </div>
    </div>
    <div>
        <label className="font-bold text-lg">チャット</label>
        <div className="mt-1 whitespace-pre-wrap">
            <>
                <div
                    ref={messagesCtrRef}
                    className="flex-1 overflow-y-auto p-5 max-h-[400px]"
                >
                {/* {messages} */}
                {localMessages.length === 0 && (
                    <div className="flex justify-center items-center h-full">
                        <div className="text-lg text-gray-500">
                            メッセージがありません
                        </div>
                    </div>
                )}
                {localMessages.length > 0 && (
                    <div className="flex-1 flex flex-col">
                        <div ref={loadMoreIntersect}></div>
                        {localMessages.map((message) => (
                            <MessageItem
                                key={message.id}
                                message={message}
                            />
                        ))}
                    </div>
                )}
                </div>
                <MessageInput meetingLogId={meetingLog.id} />
            </>
        </div>
    </div>
</div>

基本情報のときと同様に、grid grid-cols-2で2分割しています。

そして、分割する要素をdivタグで囲い、左側に面談記録、右側にチャット欄を表示させています。

面談記録は、基本情報と同じようにlabelタグとdivタグをdivタグで囲うことで表現している。

面談記録の内容を表示するdivタグには、whitespace-pre-wrap max-h-[400px] overflow-y-autoを指定して、文章の折り返しの指定とウィンドウをはみ出したときにスクロールバーを表示する指定、そして、最大で表示する高さ(400px)を指定しています。

チャット欄に関する解説は本記事では割愛。

コントローラーの実装:詳細画面の呼び出し

app/Http/Controllers/MeetingLogController.phpshowメソッドを以下のように記述します。

app/Http/Controllers/MeetingLogController.php
public function show(MeetingLog $meetingLog)
{
    $messages = Message::where('meeting_logs_id', $meetingLog->id)
        ->latest()
        ->paginate(10);

    return inertia('MeetingLog/Show', [
        'meetingLog' => new MeetingLogResource($meetingLog),
        'messages' => MessageResource::collection($messages),
    ]);
}

inertia()メソッドを用いて、先述したresources/js/Pages/MeetingLog/Show.jsxを該当する面談記録の詳細情報とともに出力しています。

こちらも、メッセージ機能については、次回以降の記事で書きます!

ルーティング

そして、ルーティングの設定をして行きます。

routes/web.php
Route::middleware(['auth', 'verified'])->group(function () {
     ////省略////
+    Route::get('/meetinglog/show/{meetingLog}', [MeetingLogController::class, 'show'])
+        ->name('meetinglog.show');
});

面談記録の新規登録機能を実装

続いて、面談記録の新規作成機能を実装していきます。

完成した画面がこちら

Create.jsx

まずは、新規登録機能のフロントエンド部分を実装していきます。
ソースは以下のとおりです。

ソースコード
import InputError from "@/Components/InputError";
import InputLabel from "@/Components/InputLabel";
import Pagenation from "@/Components/Pagenation";
import SelectInput from "@/Components/SelectInput";
import TextAreaInput from "@/Components/TextAreaInput";
import TextInput from "@/Components/TextInput";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router, useForm } from "@inertiajs/react";
import { useEffect, useState } from "react";

export default function Create ({ auth, members, meetingLogs, queryParams = null}) {
  queryParams = queryParams || {}

  const {data, setData, post, errors, reset} = useForm({
    title: "",
    user_id: auth.user.id ?? "",
    member_id: queryParams.member ?? "",
    condition: "",
    meeting_log: "",
  })

  const memberChanged = (value) => {
    if (value) {
      queryParams["member"] = value;
    } else {
      delete queryParams["member"];
    }
    console.log(queryParams)
    router.get(route('meetinglog.create'), queryParams);
  }

  const onSubmit = (e) => {
    e.preventDefault();
    console.log("onSubmit");
    post(route('meetinglog.index'));
  }

  return (
    <AuthenticatedLayout
    user={auth.user}
    header={
      <div className="flex justify-between items-center">
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
          新規作成
        </h2>
      </div>
    }
  >
      <Head title="新規作成" />
      <div className="py-12">
        <div className="flex gap-4 justify-center items-start max-w-7xl mx-auto sm:px-6 lg:px-8">
          {meetingLogs.data.length != 0 && (
            <div className="p-6 text-gray-900 w-1/2 sm:p-8 bg-white shadow sm:rounded-lg">
                {meetingLogs.data.map(meetingLog => (
                  <div>
                    <div className="grid gap-1 grid-cols-2">
                      <div>
                        <div>
                          <label className="font-bold text-lg">ID</label>
                          <p className="mt-1">{meetingLog.id}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">利用者名</label>
                          <p className="mt-1">{meetingLog.member.name}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">体調</label>
                          <p className="mt-1">{meetingLog.condition}</p>
                        </div>
                      </div>
                      <div>
                        <div>
                          <label className="font-bold text-lg">作成者</label>
                          <p className="mt-1">{meetingLog.user.name}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">事業所</label>
                          <p className="mt-1">{meetingLog.member.office.name}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">作成日</label>
                          <p className="mt-1">{meetingLog.created_at}</p>
                        </div>
                      </div>
                    </div>
                    <div>
                      <label className="font-bold text-lg">面談記録</label>
                      <div className="mt-1 whitespace-pre-wrap h-96 overflow-y-auto">{meetingLog.meeting_log}</div>
                    </div>
                  </div>
                ))}
                <Pagenation links={meetingLogs.meta.links} queryParams={queryParams}/>
            </div>
          )}
          <div className=" text-gray-900 w-1/2 bg-white shadow sm:rounded-lg">
            <form
              onSubmit={onSubmit}
              className="p-6 sm:p-8"
            >
              <div className="">
                <InputLabel
                  htmlFor="member_id"
                  value="利用者名"
                />
                <SelectInput
                  id="member_id"
                  value={queryParams.member}
                  className="mt-1 block w-full"
                  onChange={(e) => {memberChanged(e.target.value) }}
                >
                  <option value="">利用者名を選択してください</option>
                  {members.data.map(member => (
                    <option value={member.id}>{member.name}</option>
                  ))}
                </SelectInput>
                <InputError message={errors.member_id} className="mt-2" />
              </div>
              <div className="mt-4">
                <InputLabel
                  htmlFor="title"
                  value="タイトル"
                />
                <TextInput
                  id="title"
                  type="text"
                  value={data.title}
                  className="mt-1 block w-full"
                  onChange={(e) => setData("title", e.target.value)}
                />
                <InputError message={errors.title} className="mt-2" />
              </div>
              <div className="mt-4">
                <InputLabel
                  htmlFor="condition"
                  value="体調"
                />
                <SelectInput
                  id="condition"
                  value={data.condition}
                  className="mt-1 block w-full"
                  onChange={(e) => setData("condition", e.target.value)}
                >
                  <option value="">体調を選択してください</option>
                  <option value="1">1</option>
                  <option value="2">2</option>
                  <option value="3">3</option>
                  <option value="4">4</option>
                  <option value="5">5</option>
                </SelectInput>
                <InputError message={errors.condition} className="mt-2" />
              </div>
              <div className="mt-4 max-h-96">
                <InputLabel
                  htmlFor="meeting_log"
                  value="面談記録"
                />
                <TextAreaInput
                  id="meeting_log"
                  rows="14"
                  value={data.meeting_log}
                  className="mt-1 block w-full"
                  onChange={(e) => setData("meeting_log", e.target.value)}
                />
                <InputError message={errors.meeting_log} className="mt-2" />
              </div>
              <div className="mt-4 text-right">
                  <Link
                    href={route("meetinglog.index")}
                    className="bg-gray-300 py-1 px-3 text-gray-800 rounded shadow transition-all hover:bg-gray-200 mr-2"
                  >
                    戻る
                  </Link>
                  <button
                    className="bg-emerald-500 py-1 px-3 text-white rounded shadow transition-all hover:bg-emerald-400"
                  >
                    保存
                  </button>
              </div>
            </form>
          </div>
        </div>
      </div>
  </AuthenticatedLayout>
  );
}

以下パーツごとに解説していきます。

  • useForm
const {data, setData, post, errors} = useForm({
    title: "",
    user_id: auth.user.id ?? "",
    member_id: queryParams.member ?? "",
    condition: "",
    meeting_log: "",
})

useFormとはフォームのデータを扱うためのinertiaJSの機能の一つです。
Reactに同様の名前のhookがあると思いますがそれとは別物です。

dataにはフォームに入力されたデータが入ります。setData関数を用いて、データを入力していきます。

入力したいデータの形は、useForm({})の中に記述していきます。

postはデータの送信方法を表しています。

errorsはサーバー側からエラーコードやエラーメッセージを受け取ったときに、その内容が格納されます。

  • memberChanged
const memberChanged = (value) => {
    if (value) {
      queryParams["member"] = value;
    } else {
      delete queryParams["member"];
    }
    router.get(route('meetinglog.create'), queryParams);
}

memberChanged関数は、選択された利用者のidを受け取り、queryParamsに代入して、バックエンドに送信する関数です。

これによりバックエンド側にどの利用者を選択しているかを伝えており、バックエンド側から該当する利用者の過去の面談記録を取得することができるようになっています。

  • onSubmit
  const onSubmit = (e) => {
    e.preventDefault();
    post(route('meetinglog.store'));
  }

onSubmit関数は、保存ボタンクリック時にフォームデータをバックエンドへpostする関数です。

e.preventDefault()は、イベントの発生元であるフォームのデフォルトの動作をキャンセルするメソッドです。
この処理を加えることで、ボタンのイベントであるonClickなどの処理を妨害して、2重送信や送信中にデータの変更を防ぎます。

なお、同様の処理が複数ヶ所にあるため、今後リファクタリングの際に外部化する予定です。

  • return部分
ソースコード
return (
    <AuthenticatedLayout
    user={auth.user}
    header={
      <div className="flex justify-between items-center">
        <h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
          新規作成
        </h2>
      </div>
    }
  >
      <Head title="新規作成" />
      <div className="py-12">
        <div className="flex gap-4 justify-center items-start max-w-7xl mx-auto sm:px-6 lg:px-8">
          {meetingLogs.data.length != 0 && (
            <div className="p-6 text-gray-900 w-1/2 sm:p-8 bg-white shadow sm:rounded-lg">
                {meetingLogs.data.map(meetingLog => (
                  <div>
                    <div className="grid gap-1 grid-cols-2">
                      <div>
                        <div>
                          <label className="font-bold text-lg">ID</label>
                          <p className="mt-1">{meetingLog.id}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">利用者名</label>
                          <p className="mt-1">{meetingLog.member.name}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">体調</label>
                          <p className="mt-1">{meetingLog.condition}</p>
                        </div>
                      </div>
                      <div>
                        <div>
                          <label className="font-bold text-lg">作成者</label>
                          <p className="mt-1">{meetingLog.user.name}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">事業所</label>
                          <p className="mt-1">{meetingLog.member.office.name}</p>
                        </div>
                        <div className="mt-4">
                          <label className="font-bold text-lg">作成日</label>
                          <p className="mt-1">{meetingLog.created_at}</p>
                        </div>
                      </div>
                    </div>
                    <div>
                      <label className="font-bold text-lg">面談記録</label>
                      <div className="mt-1 whitespace-pre-wrap h-96 overflow-y-auto">{meetingLog.meeting_log}</div>
                    </div>
                  </div>
                ))}
                <Pagenation links={meetingLogs.meta.links} queryParams={queryParams}/>
            </div>
          )}
          <div className=" text-gray-900 w-1/2 bg-white shadow sm:rounded-lg">
            <form
              onSubmit={onSubmit}
              className="p-6 sm:p-8"
            >
              <div className="">
                <InputLabel
                  htmlFor="member_id"
                  value="利用者名"
                />
                <SelectInput
                  id="member_id"
                  value={queryParams.member}
                  className="mt-1 block w-full"
                  onChange={(e) => {memberChanged(e.target.value) }}
                >
                  <option value="">利用者名を選択してください</option>
                  {members.data.map(member => (
                    <option value={member.id}>{member.name}</option>
                  ))}
                </SelectInput>
                <InputError message={errors.member_id} className="mt-2" />
              </div>
              <div className="mt-4">
                <InputLabel
                  htmlFor="title"
                  value="タイトル"
                />
                <TextInput
                  id="title"
                  type="text"
                  value={data.title}
                  className="mt-1 block w-full"
                  onChange={(e) => setData("title", e.target.value)}
                />
                <InputError message={errors.title} className="mt-2" />
              </div>
              <div className="mt-4">
                <InputLabel
                  htmlFor="condition"
                  value="体調"
                />
                <SelectInput
                  id="condition"
                  value={data.condition}
                  className="mt-1 block w-full"
                  onChange={(e) => setData("condition", e.target.value)}
                >
                  <option value="">体調を選択してください</option>
                  <option value="1">1</option>
                  <option value="2">2</option>
                  <option value="3">3</option>
                  <option value="4">4</option>
                  <option value="5">5</option>
                </SelectInput>
                <InputError message={errors.condition} className="mt-2" />
              </div>
              <div className="mt-4 max-h-96">
                <InputLabel
                  htmlFor="meeting_log"
                  value="面談記録"
                />
                <TextAreaInput
                  id="meeting_log"
                  rows="14"
                  value={data.meeting_log}
                  className="mt-1 block w-full"
                  onChange={(e) => setData("meeting_log", e.target.value)}
                />
                <InputError message={errors.meeting_log} className="mt-2" />
              </div>
              <div className="mt-4 text-right">
                  <Link
                    href={route("meetinglog.index")}
                    className="bg-gray-300 py-1 px-3 text-gray-800 rounded shadow transition-all hover:bg-gray-200 mr-2"
                  >
                    戻る
                  </Link>
                  <button
                    className="bg-emerald-500 py-1 px-3 text-white rounded shadow transition-all hover:bg-emerald-400"
                  >
                    保存
                  </button>
              </div>
            </form>
          </div>
        </div>
      </div>
  </AuthenticatedLayout>
);

まず、flexを用いて、直下のタグ要素を横並びにします。
次に、子要素であるdivタグにw-1/2を用いて、画面と2分割するようにします。
左側に過去の面談記録を表示し、右側に新登録用のフォームを表示します。
利用者が未選択などの理由で面談記録のデータがない場合は、左側の要素が表示されないようになっています。

ソースコード
<div className="py-12">
    <div className="flex gap-4 justify-center items-start max-w-7xl mx-auto sm:px-6 lg:px-8">
        {meetingLogs.data.length != 0 && (
        <div className="p-6 text-gray-900 w-1/2 sm:p-8 bg-white shadow sm:rounded-lg">
            //// 省略 ////
        </div>
        )}
        <div className=" text-gray-900 w-1/2 bg-white shadow sm:rounded-lg">
        //// 省略 ////
        </div>
    </div>
</div>

画面左側は、詳細画面に表示される情報をレイアウトを変えて表示しています。

ソースコード
{meetingLogs.data.map(meetingLog => (
    <div>
    <div className="grid gap-1 grid-cols-2">
        <div>
            <div>
                <label className="font-bold text-lg">ID</label>
                <p className="mt-1">{meetingLog.id}</p>
            </div>
            <div className="mt-4">
                <label className="font-bold text-lg">利用者名</label>
                <p className="mt-1">{meetingLog.member.name}</p>
            </div>
            <div className="mt-4">
                <label className="font-bold text-lg">体調</label>
                <p className="mt-1">{meetingLog.condition}</p>
            </div>
        </div>
        <div>
            <div>
                <label className="font-bold text-lg">作成者</label>
                <p className="mt-1">{meetingLog.user.name}</p>
            </div>
            <div className="mt-4">
                <label className="font-bold text-lg">事業所</label>
                <p className="mt-1">{meetingLog.member.office.name}</p>
            </div>
            <div className="mt-4">
                <label className="font-bold text-lg">作成日</label>
                <p className="mt-1">{meetingLog.created_at}</p>
            </div>
        </div>
    </div>
    <div>
        <label className="font-bold text-lg">面談記録</label>
        <div className="mt-1 whitespace-pre-wrap h-96 overflow-y-auto">{meetingLog.meeting_log}</div>
    </div>
    </div>
))}
<Pagenation links={meetingLogs.meta.links} queryParams={queryParams}/>

画面右側は、各種コンポーネントを利用して登録フォームを作っています。

ソースコード
 <form
    onSubmit={onSubmit}
    className="p-6 sm:p-8"
>
    <div className="">
    <InputLabel
        htmlFor="member_id"
        value="利用者名"
    />
    <SelectInput
        id="member_id"
        value={queryParams.member}
        className="mt-1 block w-full"
        onChange={(e) => {memberChanged(e.target.value) }}
    >
        <option value="">利用者名を選択してください</option>
        {members.data.map(member => (
        <option value={member.id}>{member.name}</option>
        ))}
    </SelectInput>
    <InputError message={errors.member_id} className="mt-2" />
    </div>
    <div className="mt-4">
    <InputLabel
        htmlFor="title"
        value="タイトル"
    />
    <TextInput
        id="title"
        type="text"
        value={data.title}
        className="mt-1 block w-full"
        onChange={(e) => setData("title", e.target.value)}
    />
    <InputError message={errors.title} className="mt-2" />
    </div>
    <div className="mt-4">
    <InputLabel
        htmlFor="condition"
        value="体調"
    />
    <SelectInput
        id="condition"
        value={data.condition}
        className="mt-1 block w-full"
        onChange={(e) => setData("condition", e.target.value)}
    >
        <option value="">体調を選択してください</option>
        <option value="1">1</option>
        <option value="2">2</option>
        <option value="3">3</option>
        <option value="4">4</option>
        <option value="5">5</option>
    </SelectInput>
    <InputError message={errors.condition} className="mt-2" />
    </div>
    <div className="mt-4 max-h-96">
    <InputLabel
        htmlFor="meeting_log"
        value="面談記録"
    />
    <TextAreaInput
        id="meeting_log"
        rows="14"
        value={data.meeting_log}
        className="mt-1 block w-full"
        onChange={(e) => setData("meeting_log", e.target.value)}
    />
    <InputError message={errors.meeting_log} className="mt-2" />
    </div>
    <div className="mt-4 text-right">
        <Link
        href={route("meetinglog.index")}
        className="bg-gray-300 py-1 px-3 text-gray-800 rounded shadow transition-all hover:bg-gray-200 mr-2"
        >
        戻る
        </Link>
        <button
        className="bg-emerald-500 py-1 px-3 text-white rounded shadow transition-all hover:bg-emerald-400"
        >
        保存
        </button>
    </div>
</form>

各コンポーネントで次節で説明します。

Create.jsxのコンポーネント

  • TextInput
ソースコード
import { forwardRef, useEffect, useRef } from 'react';

export default forwardRef(function TextInput({ type = 'text', className = '', isFocused = false, ...props }, ref) {
    const input = ref ? ref : useRef();

    useEffect(() => {
        if (isFocused) {
            input.current.focus();
        }
    }, []);

    return (
        <input
            {...props}
            type={type}
            className={
                'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm ' +
                className
            }
            ref={input}
        />
    );
});


TextInputはテキスト入力欄を構成するコンポーネントです。

forwardRefを呼び出すことで、コンポーネントがrefを受け取り、それを子コンポーネントに転送できるようになります。

最新の公式ドキュメントでは、親コンポーネントから子コンポーネントにrefを渡すことができます。
https://ja.react.dev/learn/manipulating-the-dom-with-refs

おそらく、以前のバージョンではこのような運用ができず、forwardRefを利用してのみ、refの受け渡しができていたのだと思います。

公式ドキュメントで紹介されている利用例を見ると、テキスト入力欄のオートフォーカスなどがあるみたいです。

ページが表示された瞬間に、自動的にテキスト入力欄をフォーカスしたいときなどに使われるのだと思います。

今回のアプリケーションではそういった機能の実装は現段階でしていないので、forwarRefは不要なのかもしれません。

  • TextAeraInput
ソースコード
import { forwardRef, useEffect, useRef } from 'react';

export default forwardRef(function TextAreaInput({ className = '', isFocused = false, ...props }, ref) {
    const input = ref ? ref : useRef();

    useEffect(() => {
        if (isFocused) {
            input.current.focus();
        }
    }, []);

    return (
        <textarea
            {...props}
            className={
                'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm ' +
                className
            }
            ref={input}
        />
    );
});


TextAreaInputは、TextInputinputタグをtextareaに変更したものになります。

  • InputLabel
ソースコード
export default function InputLabel({ value, className = '', children, ...props }) {
    return (
        <label {...props} className={`block font-medium text-sm text-gray-700 dark:text-gray-300 ` + className}>
            {value ? value : children}
        </label>
    );
}


InputLabelコンポーネントは、フォームの項目名を表示するためのコンポーネントです。

labelタグを用いて、表現しています。

追加のCSSはclassNameに記述します。

ラベルに表示する値を指定したい場合は、valueに値を代入して、valueが存在する場合はvalueの値がlabelの子要素として表示され、存在しない場合はコンポーネントの子要素(children)が表示されます。

  • InputError
ソースコード
export default function InputError({ message, className = '', ...props }) {
    return message ? (
        <p {...props} className={'text-sm text-red-600 dark:text-red-400 ' + className}>
            {message}
        </p>
    ) : null;
}


InputErrorは、各項目に指定したmessageに値が存在していた場合、そのメッセージを表示するためのコンポーネントです。

今回のアプリケーションでは主に、バリデーションチェック時のエラーを表示するために用いています。

コントローラーの実装:新規登録画面呼び出し

MeetingLogController.phpcreate()メソッドを編集して、新規登録画面を呼び出せるようにします。

ソースコード
app/Http/Controllers/MeetingLogController.php
public function create()
    {
        $query = MeetingLog::query();
        $meetingLogs = [];

        if (request("member")) {
            $query->where("member_id", "=", request("member"));
            $meetingLogs = $query->orderBy("created_at", "desc")->paginate(1);
        }

        $members = Member::where('office_id', '=', Auth::user()->office_id)->get();
        $queryParams = request()->query();

        return inertia("MeetingLog/Create", [
            'members' => MemberResource::collection($members),
            'meetingLogs' => MeetingLogResource::collection($meetingLogs) ?? [],
            'queryParams' => $queryParams ?: null,
        ]);
    }

  • クエリ生成と初期化
$query = MeetingLog::query();
$meetingLogs = [];

まず、query()メソッドを用いて、面談記録のクエリを生成します。
また、面談記録の一覧を格納する変数meetingLogsを初期化しておきます。

  • 面談記録のフィルタリング
if (request("member")) {
    $query->where("member_id", "=", request("member"));
    $meetingLogs = $query->orderBy("created_at", "desc")->paginate(1);
}

フロントエンド側から指定された利用者idが存在している場合、そのidに基づいた面談記録を作成日時で降順に並び替えた後、1ページごとのページネーションとして取得します。

  • 利用者一覧とURLパラメーターの取得
    $members = Member::where('office_id', '=', Auth::user()->office_id)->get();
    $queryParams = request()->query();

$membersにアカウントの所属する事業所の利用者を取得し格納します。
また、URLパラメーターを$queryParamsに格納しています。

  • データの送信
return inertia("MeetingLog/Create", [
    'members' => MemberResource::collection($members),
    'meetingLogs' => MeetingLogResource::collection($meetingLogs) ?? [],
    'queryParams' => $queryParams ?: null,
]);

inertia()メソッドを用いて、MeetingLog/Create.jsxに対してデータを送信します。
前回記事で開設したMeetingLogControllerの時と同様に、対応するリソースクラスに定義されたデータの形で送信しています。

リクエストの実装

面談記録をデータベースに新規登録する際、バリデーション処理を行うためにリクエストファイルを実装していきます。

ソースコード
app/Http/Requests/StoreMeetingLogRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreMeetingLogRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            "title" => ['required', 'max:255'],
            "user_id" => ['required', 'integer'],
            "member_id" => ['required', 'integer'],
            "condition" => ['required', 'integer', Rule::in(1, 2, 3, 4, 5)],
            "meeting_log" => ['required', 'string'],
        ];
    }
}
  • リクエストの許可
    authorize()メソッドは、フォームリクエストを認可する条件を設定することができます。

例えば、ユーザーが編集しようとしているブログ記事を実際に所有しているか胴かを判断できます。

public function authorize(): bool
{
    return true;
}

今回は、全てのリクエストを許可するようにしています。

  • バリデーションルールの設定
/**
 * Get the validation rules that apply to the request.
 *
 * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
 */
public function rules(): array
{
    return [
        "title" => ['required', 'max:255'],
        "user_id" => ['required', 'integer'],
        "member_id" => ['required', 'integer'],
        "condition" => ['required', 'integer', Rule::in(1, 2, 3, 4, 5)],
        "meeting_log" => ['required', 'string'],
    ];
}

rule()メソッドにバリデーションルールを設定することができます。

キーのところに、指定するカラム名を、値のところにルールを記述していきます。

今回の場合は、全てを必須項目にするためにrequiredをしていています。

titleは255文字までという条件を追加し、他のカラムはデータ型を指定しています。

conditionは1から5までの数字のみ許容したいので、Ruleクラスのinメソッドを用います。

コントローラーの実装:新規登録処理

MeetingLogController.phpstore()メソッドを編集して、新規登録画面で入力した情報をDBに保存する機能を実装します。

app/Http/Controllers/MeetingLogController.php
public function store(StoreMeetingLogRequest $request)
{
    $data = $request->validated();
    MeetingLog::create($data);

    return to_route('meetinglog.index');
}

requestの内容をバリデーションした後、dataに格納して、面談記録(MeetingLog)テーブルにcreate()メソッドを利用して、保存しています。

ルーティング

最後に、ルーティングの設定をして行きます。

Route::middleware(['auth', 'verified'])->group(function () {
    ////省略////
+    Route::get('/meetinglog/create', [MeetingLogController::class, 'create'])
+        ->name('meetinglog.create');
});

おわりに

今回は、面談記録の詳細ページ閲覧機能と新規作成機能を実装しました。

次回は、面談記録の編集と削除機能を解説して、CRUD処理の解説を一通り終わらせていきたいと思います!

ではでは!

Discussion