🙆

利用者管理アプリ開発:面談記録一覧表示機能の実装

2024/12/18に公開

はじめに

前回は、データーベースのテーブル設計と構築を行いました!

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

今回は、面談記録一覧表示機能を実装していきます!

フィーチャーブランチの作成

今回は、データベース構築部分の実装していきたいので、それ専用のブランチを作成します。

作成するには以下のコマンドを実行します。
ブランチ名はmeetinglogにしています。

git flow feature start meetinglog

ブランチが作成されているか確認します。

git branch
  develop
* feature/meetinglog
  main

無事作成されていることがわかりました。

ブランチをGitHub上にアップロード

今のままでは、ローカル上にブランチが存在しているだけなので、GitHub上には反映していきます。
反映するには以下のコマンドを実行します。

git flow feature publish meetinglog 

GitHub上で確認して、ブランチが追加さていれば成功です。

AuthenticatedLayout.jsxの編集

まずは、全ての画面で使用する予定のAuthenticatedLayout.jsxを編集していきます。

ナビゲーションバーの編集

まずは、ナビゲーションバーの部分を修正していきます。

該当箇所を以下のように、修正していきます。

resources/js/Layouts/AuthenticatedLayout.jsx
////省略////
import { HomeIcon } from '@heroicons/react/24/solid'
////省略////
<div className="shrink-0 flex items-center">
    <Link href="/">
        <HomeIcon className="block h-9 w-auto fill-current text-gray-800 dark:text-gray-200" />
    </Link>
</div>

<div className="hidden space-x-4 sm:-my-px sm:ms-10 sm:flex">
    <NavLink href={route('dashboard')} active={route().current('dashboard')}>
        ダッシュボード
    </NavLink>
    <NavLink href={route('meetinglog.index')} active={route().current('meetinglog.index')}>
        面談記録
    </NavLink>
</div>
////省略////

まず、デフォルトのアイコンからHomeIconに変更しています。

次に、デフォルトではDashboardのみしか記載されていませんが、面談記録のナビゲーションを追加しています。

また、各ナビゲーション間のスペースが広すぎるので、ClassNamespace-x-6space-x-4に変更しています。

次に、DropdownタグとResponsiveNavLinkタグ内の英語のメニュー名を日本語に修正します。
まずは、Dropdown.Contentから

resources/js/Layouts/AuthenticatedLayout.jsx
<Dropdown.Content>
    <Dropdown.Link href={route('profile.edit')}>プロフィール編集</Dropdown.Link>
    <Dropdown.Link href={route('logout')} method="post" as="button">
        ログアウト
    </Dropdown.Link>
</Dropdown.Content>

次に、ResponsiveNavLinkタグ内を編集し、面談記録ページへリンクした項目も追加します。

resources/js/Layouts/AuthenticatedLayout.jsx
<div className="pt-2 pb-3 space-y-1">
    <ResponsiveNavLink href={route('dashboard')} active={route().current('dashboard')}>
        ダッシュボード
    </ResponsiveNavLink>
    <ResponsiveNavLink href={route('meetinglog.index')} active={route().current('meetinglog.index')}>
        面談記録
    </ResponsiveNavLink>
</div>

<div className="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
    <div className="px-4">
        <div className="font-medium text-base text-gray-800 dark:text-gray-200">{user.name}</div>
        <div className="font-medium text-sm text-gray-500">{user.email}</div>
    </div>

    <div className="mt-3 space-y-1">
        <ResponsiveNavLink href={route('profile.edit')}>プロフィール編集</ResponsiveNavLink>
        <ResponsiveNavLink method="post" href={route('logout')} as="button">
            ログアウト
        </ResponsiveNavLink>
    </div>
</div>

面談記録へリンクした項目を追加し、英語表記になっているメニュー名を日本語に直しただけです。

MeegingLogコントローラーの生成

面談記録のCRUD処理を実装するために、コントローラーを生成していきます。

コントローラーの生成には以下のコマンドを実行します。

php artisan make:controller MeetingLogController --model=MeetingLog --requests

これにより、MeetingLogController.phpStoreMeetingLogRequest.phpUpdateMeetingLogRequest.phpが作成されます。

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

まずは、面談記録の一覧表示させる機能を実装しましょう!

Index.jsxの作成

面談記録一覧のフロントエンド部分を作成していきます!

resources/js/Pages内にMeetingLogディレクトリを作成します。

その後、resources/js/Pages/MeetingLog内に、Index.jsxファイルを作成します。

作成したIndex.jsxファイルを以下のように記述して、面談記録一覧画面を実装していきます。
https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Pages/MeetingLog/Index.jsx

  • Props
    • 表示するデータであるmeetinglogs、絞り込みに必要なデータであるoffices/users/members、絞り込むパラメータであるqueryParamsをPropsとして渡しています。
  • return部分
    • AuthenticatedLayoutでナビゲーションバーを表示させ、その下に面談記録の一覧を表形式で表示します。
    • 各項目にはTableHeadコンポーネント(後述)を利用して、項目をクリックすることで昇順降順に並べ替えることができます。
    • TextInputSelectInputコンポーネントを利用した各項目の検索ウィンドウを設けており、面談記録を絞り込むことができます。
  • searchFieldChanged
    • searchFieldChangedは、TextInputなどに入力された絞り込みのための検索ワードをqueryParamsに代入して、バックエンドに渡すための関数です。
  • onKeyPress
    • onKeyPressは、TextInputで文字を入力してEnterキーが押されたとき、入力された文字列をsearchFieldChangedに渡す関数です。
  • sortChanged
    • ソートしたい項目がクリックされたときに、その項目と昇順か降順をqueryParamsに代入して、バックエンドに渡すための関数です。
  • deleteMeetingLog
    • 本当に削除するかどうかの確認をするためのウィンドウを表示した後、削除する場合は削除リクエストをバックエンドに伝える関数です。

TableHeadingコンポーネントの作成

最初は、全てのコードをIndex.jsxに記述していたのですが、一覧表のヘッダーの部分をコンポーネントとして分けました。
https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Components/TableHeading.jsx

  • Props
    • どの項目かを判別するためのname、ソート機能を表示するかどうかを判別するためのsortable
    • 現在のqueryParamsに代入されている項目と昇順か降順を取得するためのsort_fieldsort_direction
    • 該当項目がクリックされたときにqueryParamsに項目と昇順か降順かを伝えるためのsortChanged関数
    • 子要素であるchildren
  • return部分
    • tableタグ内で各項目を表示するときに使われるコンポーネントなので、全体をthタグで囲んでいます。
    • HeroIconsライブラリからChevronUpIconChevronDownIconを利用して、昇順と降順のマークを表示しています。

Pagenationコンポーネントの作成

TableHeading.jsxと同様にページの出力部分もコンポーネントして分けています。
https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Components/Pagenation.jsx

  • Props
    • ページ遷移先のURLが格納されているlinks
    • 検索や絞り込みを行っていた場合、ページをまたいでもその条件を引き継ぐためにqueryParamsも持ってきます。
  • return部分
    • navタグ内でlinksを展開してそれぞれLinkタグで表現しています。
    • hrefにはlinkのURLの末尾にgetUrlParamsを利用して適切なパラメータを付与しています。
    • link.activetrueの場合、背景色を追加します。
    • link.urlの値が存在しない場合、!text-gray-500で強制的にテキストの色を変更して、cursor-not-allowedで、マウスオーバーしたときに禁止マークを表示するようにしています。
  • getUrlParams
    • 親要素から持ってきたqueryParamsのキーを抽出して、page意外のキーがある場合は、URLパラメーターの形に整形して値を返す関数です。
  • isPrevNext
    • linksオブジェクト内のlabelキーがpagination.previousだった場合は「< 前へ」、pagination.nextだった場合は「次へ >」と表示すると用にする関数です。

SelectInputコンポーネントの作成

https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Components/SelectInput.jsx

  • Props
    • コンポーネントのCSSを利用個所に合わせて調整するためのclassName
    • 子要素を格納したchildren
    • その他selectタグに指定する要素を記述した...props
  • return部分
    • selectタグを用いて、セレクトボックスを生成しています。

MeetingLogController.phpの編集

続いて、バックエンドの処理実装のためにコントローラーを編集していきます。

MeetingLogControllerindex()メソッドに実装していきます。

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

    $users = User::all();
    $offices = Office::all();
    $members = Member::all();

    if(!Auth::user()->is_global_admin) {
        $officeId = Auth::user()->office_id;
        $query->select("meeting_logs.*", "offices.id as office_id")
            ->leftJoin('members', 'meeting_logs.member_id', '=', 'members.id')
            ->leftJoin('offices', 'members.office_id', '=', 'offices.id')
            ->where('offices.id', '=', $officeId);
        $users = User::where("office_id", "=", $officeId)->get();
        $members = Member::where("office_id", "=", $officeId)->get();
    }

    $sortField = request("sort_field", "created_at");
    $sortDirection = request("sort_direction", "desc");

    if (request("id")) {
        $query->where("id", "=", request("id"));
    }

    if (request("title")) {
        $query->where("title", "like", "%" . request("title") . "%");
    }

    if (request("user")) {
        $query->where("user_id", "=", request("user"));

        $officeId = User::select("office_id")->where("id", "=", request("user"));
        $offices = Office::where("id", "=", $officeId)->get();
        $members = Member::where("office_id", "=", $officeId)->get();
    }

    if (request("member")) {
        $query->where("member_id", "=", request("member"));

        $officeId = Member::select("office_id")->where("id", "=", request("member"));
        $offices = Office::where("id", "=", $officeId)->get();
        $users = User::where("office_id", "=", $officeId)->get();
    }

    if (request("office")) {
        $query->select("meeting_logs.*", "offices.id as office_id")
            ->leftJoin('members', 'meeting_logs.member_id', '=', 'members.id')
            ->leftJoin('offices', 'members.office_id', '=', 'offices.id')
            ->where('offices.id', '=', request("office"));
        $members = Member::where("office_id", "=", request("office"))->get();
        $users = User::where("office_id", "=", request("office"))->get();
    }

    if (request("condition")) {
        $query->where("condition", "=", request("condition"));
    }

    if ($sortField == "office_id") {
        if (!request("office")) {
            $query->select("meeting_logs.*", "offices.id as office_id")
                ->leftJoin('members', 'meeting_logs.member_id', '=', 'members.id')
                ->leftJoin('offices', 'members.office_id', '=', 'offices.id');
        }
    }

    $meetingLogs = $query->orderBy($sortField, $sortDirection)->paginate(10);

    $queryParams = request()->query();

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

  • 初期設定
$query = MeetingLog::query();

$users = User::all();
$offices = Office::all();
$members = Member::all();

まず、MeetingLog::query()はクエリビルダーを使ってMeetingLogモデルに対するクエリを構築します。
次に、all()メソッドを用いて、従業員・事業所・利用者の全レコードを一旦取得しています。

  • 最上位権限者によるフィルタリング
if(!Auth::user()->is_global_admin) {
    $officeId = Auth::user()->office_id;
    $query->select("meeting_logs.*", "offices.id as office_id")
        ->leftJoin('members', 'meeting_logs.member_id', '=', 'members.id')
        ->leftJoin('offices', 'members.office_id', '=', 'offices.id')
        ->where('offices.id', '=', $officeId);
    $users = User::where("office_id", "=", $officeId)->get();
    $members = Member::where("office_id", "=", $officeId)->get();
}

最上位権限者は、全ての面談記録を閲覧することができる。
それ以外のユーザーは、自身の所属する事業所に付随する情報のみを閲覧することができます。

  • ソートの設定
$sortField = request("sort_field", "created_at");
$sortDirection = request("sort_direction", "desc");

sortFieldはソートする要素を、sortDirectionはソートの方向を格納する変数です。
デフォルトで、sortFieldは作成日時を、sortDirectionは降順を指定しています。

  • 絞り込みの処理
if (request("title")) {
            $query->where("title", "like", "%" . request("title") . "%");
}

if (request("user")) {
    $query->where("user_id", "=", request("user"));

    $officeId = User::select("office_id")->where("id", "=", request("user"));
    $offices = Office::where("id", "=", $officeId)->get();
    $members = Member::where("office_id", "=", $officeId)->get();
}
////省略////

フロントエンド側から受け取ったデータからrequest()メソッドを用いて該当するキーに対応した値を取り出します。

値が存在する場合、where句を用いて、フィルタリングを行います。

titleといったテキスト入力による絞り込みに対しては、like句とワイルドカード%を用いて部分一致検索するようにしています。

  • クエリの実行とデータの送信
$meetingLogs = $query->orderBy($sortField, $sortDirection)->paginate(10);

$queryParams = request()->query();

return inertia("MeetingLog/Index", [
    'meetingLogs' => MeetingLogResource::collection($meetingLogs),
    'offices' => OfficeResource::collection($offices),
    'users' => UserResource::collection($users),
    'members' => MemberResource::collection($members),
    'queryParams' => $queryParams ?: null,
]);

ここでは、orderByで該当するカラムに対してソートをかけた後、paginate()を用いて結果を10件ずつのページネーション形式で取得しています。

さらに、URLパラメーターをrequest()->query()を用いて連想配列として$queryParamsに格納しています。

そして、inertia()メソッドを用いて、MeetingLog/Index.jsxに対してデータを送信します。
その際、MeetingLogResourceなどのリソースクラス(後述)に定義したデータの形で送信します。

Resourceの作成

バックエンドからフロントエンドにデータを流す際のデータの形を決める、Resourceを作成していきます。

Resourceの作成には以下のコマンドを実行します。

php artisan make:resource MeetingLogResource
php artisan make:resource OfficeResource
php artisan make:resource UserResource
php artisan make:resource MemberResource

生成されたリソースファイルは、app/Http/Resources内に配置されます。

  • MeetingLogResource
    面談記録のリソースファイルであるMeetingLogResource.phpを以下のように書き換えます。
app/Http/Resources/MeetingLogResource.php
////省略////
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'user' => new UserResource($this->user),
        'member' => new MemberResource($this->member),
        'condition' => $this->condition,
        'meeting_log' => $this->meeting_log,
        'created_at' => (new Carbon($this->created_at))->format('Y-m-d'),
        'updated_at' => (new Carbon($this->updated_at))->format('Y-m-d'),
    ];
}
////省略////
  • OfficeResource
    事業所のリソースファイルであるOfficeResource.phpを以下のように書き換えます。
app/Http/Resources/OfficeResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'zip_code' => $this->zip_code,
        'address' => $this->address,
        'phone_number' => $this->phone_number,
        'created_at' => (new Carbon($this->created_at))->format('Y-m-d'),
        'updated_at' => (new Carbon($this->updated_at))->format('Y-m-d'),
    ];
}
  • UserResource
    職員のリソースファイルであるUserResource.phpを以下のように書き換えます。
app/Http/Resources/UserResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'avatar' => $this->avatar,
        'email' => $this->email,
        'office' => new OfficeResource($this->office),
        'is_admin' => $this->is_admin,
        'is_main_office' => $this->is_main_office,
        'created_at' => (new Carbon($this->created_at))->format('Y-m-d'),
        'updated_at' => (new Carbon($this->updated_at))->format('Y-m-d'),
    ];
}
  • MemberResource
    利用者のリソースファイルであるMemberResource.phpを以下のように書き換えます。
app/Http/Resources/MemberResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'sex' => $this->sex,
        'office' => new OfficeResource($this->office),
        'status' => $this->status,
        'characteristics' => $this->characteristics,
        'notes' => $this->notes,
        'created_at' => (new Carbon($this->created_at))->format('Y-m-d'),
        'updated_at' => (new Carbon($this->updated_at))->format('Y-m-d'),
    ];
}

web.phpの編集

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

routes/web.php
 <?php

+ use App\Http\Controllers\MeetingLogController;
////省略////
- Route::get('/', function () {
-     return Inertia::render('Welcome', [
-         'canLogin' => Route::has('login'),
-         'canRegister' => Route::has('register'),
-         'laravelVersion' => Application::VERSION,
-         'phpVersion' => PHP_VERSION,
-     ]);
- });

+ Route::redirect('/', '/dashboard');

- Route::get('/dashboard', function () {
-     return Inertia::render('Dashboard');
- })->middleware(['auth', 'verified'])->name('dashboard');

+ Route::middleware(['auth', 'verified'])->group(function () {
+     Route::get('/dashboard', fn() => Inertia::render('Dashboard'))
+         ->name('dashboard');

+     Route::get('/meetinglog', [MeetingLogController::class, 'index'])
+         ->name('meetinglog.index');

+     Route::get('/meetinglog/show/{meetingLog}', [MeetingLogController::class, 'show'])
+         ->name('meetinglog.show');

+     Route::get('/meetinglog/create', [MeetingLogController::class, 'create'])
+         ->name('meetinglog.create');

+     Route::post('/meetinglog', [MeetingLogController::class, 'store'])
+         ->name('meetinglog.store');

+     Route::delete('/meetinglog/destroy/{meetingLog}', [MeetingLogController::class, 'destroy'])
+         ->name('meetinglog.destroy');

+     Route::get('/meetinglog/edit/{meetingLog}', [MeetingLogController::class, 'edit'])
+         ->name('meetinglog.edit');

+     Route::put('/meetinglog/update/{meetingLog}', [MeetingLogController::class, 'update'])
+         ->name('meetinglog.update');

+     Route::patch('/meetinglog/update/{meetingLog}', [MeetingLogController::class, 'update'])
+         ->name('meetinglog.update');
+ });

////省略////

表示確認

ここまでできたら実際にブラウザでhttp://localhost:8000/meetinglogにアクセスして表示を確認してみます。

以下のようなページが表示されていればOKです。

おわりに

面談記録の一覧表示機能を実装しました!

次回は、新規作成ページとデータの保存機能を実装していきます!

ではでは!

Discussion