0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(フロントエンド実装編⑧前編)~研究室詳細・レビュー画面作成前編~

Last updated at Posted at 2026-01-22

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その29)

0. 初めに

こんにちは!
見習いエンジニアの僕がWebアプリケーションをゼロから開発するシリーズです。

今日は、ついにアプリの目玉となる研究室詳細・レビュー画面を作成していきたいと思います!

1. ブランチ運用

いつも通り、developブランチを最新化して、新規ブランチfeature/frontend/lab-detail-pageを切って作業します。

こまめなコミットを心がけましょう。

2. 画面デザイン

こちらも例によって、完成イメージを共有してから実装に臨みましょう。
image.png

3. コントローラー修正

必要な情報が一部足りていなかったので追加しておきました。

\project-root\src\app\Http\Controllers\LabController.php
    public function show(Lab $lab, Request $request)
    {
        // 大学・学部、レビューのデータも一緒に渡す
        // universityはfacultyを経由して取得
        $lab->load(['faculty.university', 'reviews']);

        // コメントデータを取得(投稿者情報も含む)
        $comments = $lab->comments()->with('user')->latest()->get();

        // 平均値を計算するために、評価項目のカラム名を定義
        $ratingColumns = [
            'mentorship_style',
            'lab_atmosphere',
            'achievement_activity',
            'constraint_level',
            'facility_quality',
            'work_style',
            'student_balance',
        ];

        // 1. 各評価項目のユーザー間の平均値 (Average per Item) を計算
        $averagePerItem = collect($ratingColumns)->mapWithKeys(function ($column) use ($lab) {
            // 各評価項目の平均を計算(全レビューを対象)
            return [$column => $lab->reviews->avg($column)];
        });

        // 2. 新しい「総合評価」:各項目の平均値のさらに平均を計算
        // $averagePerItem の値(平均点)をコレクションとして取り出し、その平均を求める
        $overallAverage = $averagePerItem->avg();

        // 3. 現在のユーザーのレビューを取得
        $userReview = null;
        $userOverallAverage = null;
        
        if (Auth::check()) {
            $userReview = $lab->reviews->where('user_id', Auth::id())->first();
            
            // ユーザーのレビューが存在する場合、個別の総合評価を計算
            if ($userReview) {
                $userRatings = collect($ratingColumns)->map(function ($column) use ($userReview) {
                    return $userReview->$column;
                })->filter(function ($value) {
                    return $value !== null;
                });
                
                $userOverallAverage = $userRatings->avg();
            }
        }

        // ユーザーのブックマーク状態を取得
        $userBookmark = $lab->bookmarks()->where('user_id', Auth::id())->first();
        $bookmarkCount = $lab->bookmarks()->count();

        // 検索クエリを取得
        $searchQuery = $request->input('query', '');

        // 研究室のデータに加えて、求めたレビューの平均値とユーザーのレビュー、コメント、ブックマーク、認証情報も一緒に渡す
        return Inertia::render('Lab/Show', [
            'lab' => $lab,
            'overallAverage' => $overallAverage,
            'averagePerItem' => $averagePerItem,
            'userReview' => $userReview,
            'userOverallAverage' => $userOverallAverage,
            'userBookmark' => $userBookmark,
            'bookmarkCount' => $bookmarkCount,
            'query' => $searchQuery,
            'ratingData' => [
                'columns' => $ratingColumns,
            ],
            'comments' => $comments,
            'auth' => [
                'user' => Auth::user(),
            ],
        ]);
    }

4. レーダーチャート作成(UI作成)

評価指標の可視化には、レーダーチャートを採用したいと思います。

Chart.jsを導入する

レーダーチャートなどのグラフを作成するために便利な、Chart.jsを今回は使ってみたいと思います。

Dockerコンテナの中で以下のコマンドを実行して、インストールしましょう。
実行コマンド

/var/www
$ npm install chart.js react-chartjs-2

Lab/Show.jsxを作成する

バックエンド編で既に作成済みのファイルですが、中身をすべて消して一から作りなおしましょう。

import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

const Show = ({ lab, averagePerItem }) => {
  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: [
      {
        label: "全投稿者の平均評価",
        data: averageRatings,
        backgroundColor: "rgba(51, 225, 237, 0.2)",
        borderColor: "rgba(51, 225, 237, 1)",
        borderWidth: 2,
        pointBackgroundColor: "rgba(51, 225, 237, 1)",
        pointBorderColor: "#fff",
        pointHoverBackgroundColor: "#fff",
        pointHoverBorderColor: "rgba(51, 225, 237, 1)",
      },
    ],
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div className="overflow-hidden sm:rounded-lg">
            <div className="p-6">
              {lab.reviews && lab.reviews.length > 0 ? (
                <div className="flex justify-center">
                  <div className="w-full max-w-sm">
                    <Radar data={chartData} options={chartOptions} />
                  </div>
                </div>
              ) : (
                <p className="text-center text-gray-500">
                  まだレビューがありません
                </p>
              )}
            </div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
};

export default Show;

こんな感じになっております!
image.png

内容を解説していきますね。

import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

必要なモジュールをインポートしています。
例によって、コンテンツをAppLayoutで囲みます。

Chart.jsでは、初期設定で必要な機能を明示的に登録する必要があります。

ChartJS.register(
  RadialLinearScale,  // レーダーチャートの放射状スケール
  PointElement,       // データポイント(点)
  LineElement,        // 線
  Filler,            // 塗りつぶし
  Tooltip,           // ホバー時のツールチップ
  Legend             // 凡例
);

オブジェクト型のaveragePerItemをUI側で扱いやすくするために配列に変換しています。

  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

念のため、||を用いてnullundefinedの場合に0を表示するようにしています。

レーダーチャートの見た目や動作を細かく制御する設定オブジェクトを定義します。

const chartOptions = {
  responsive: true,          // レスポンシブ対応
  maintainAspectRatio: true, // アスペクト比を維持
  scales: { ... },           // 軸の設定
  plugins: { ... },          // プラグインの設定
};

scales.rは放射状のスケールについて設定できるプロパティです。

scales: {
  r: {  // "r"はradial(放射状)の意味
    angleLines: {
      display: true,  // 中心から各頂点への線を表示
    },
    suggestedMin: 0,  // 最小値
    suggestedMax: 5,  // 最大値
    ticks: {
      stepSize: 1,    // 目盛りの間隔(0,1,2,3,4,5)
      font: {
        size: 12,     // 目盛り数字のフォントサイズ
      },
    },
    pointLabels: {
      font: {
        size: 14,     // 各頂点のラベル("指導スタイル"など)のフォントサイズ
      },
    },
  },
},

プラグインの設定もできます。

plugins: {
  legend: {
    position: "top",  // 凡例をチャート上部に配置
  },
  tooltip: {
    callbacks: {
      label: (context) => {
        return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
      },
    },
  },
},

tooltipはホバー時に表示されます。
image.png

${context.dataset.label}は、凡例のラベルを、${context.raw.toFixed(2)}は評価値の小数点以下2桁分を表します。

実際に使うときは、別途react-chartjs-2からインポートしたRadarを使います。
propsに先ほど定義したデータとオプション設定を渡します。

<Radar data={chartData} options={chartOptions} />

react-chartjs-2は、Chart.jsの機能をReactで使いやすくしてくれたライブラリです。

5. 研究室情報表示部分作成(UI作成)

次に、研究室情報を表示します。

2列構成にする

グリッドレイアウトを採用して、2列構成にしたいと思います。

import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

const Show = ({ lab, averagePerItem }) => {
  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: [
      {
        label: "全投稿者の平均評価",
        data: averageRatings,
        backgroundColor: "rgba(51, 225, 237, 0.2)",
        borderColor: "rgba(51, 225, 237, 1)",
        borderWidth: 2,
        pointBackgroundColor: "rgba(51, 225, 237, 1)",
        pointBorderColor: "#fff",
        pointHoverBackgroundColor: "#fff",
        pointHoverBorderColor: "rgba(51, 225, 237, 1)",
      },
    ],
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div className="overflow-hidden sm:rounded-lg">
            <div className="p-6">
              <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
                {/* 左側: レーダーチャート */}
                <div className="flex justify-center items-start">
                  {lab.reviews && lab.reviews.length > 0 ? (
                    <div className="w-full max-w-sm">
                      <Radar data={chartData} options={chartOptions} />
                    </div>
                  ) : (
                    <p className="text-center text-gray-500">
                      まだレビューがありません
                    </p>
                  )}
                </div>

                {/* 右側: 研究室概要 */}
                <div>
                  <h2 className="text-base font-semibold text-gray-800 mb-2">
                    研究室概要
                  </h2>
                  <p className="text-sm text-gray-600 whitespace-pre-wrap leading-tight">
                    {lab.description || "概要はまだ登録されていません"}
                  </p>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
};

export default Show;

こんな感じになりました!
image.png

この前後にいろいろと情報をつけ足していきましょう。

総合評価を追加する

前回作成した、Lab/Indexと同様にStarRatingを使用して、総合評価値を視覚化します。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import StarRating from "@/Components/Lab/Star/StarRating";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

const Show = ({ lab, averagePerItem, overallAverage }) => {
  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: [
      {
        label: "全投稿者の平均評価",
        data: averageRatings,
        backgroundColor: "rgba(51, 225, 237, 0.2)",
        borderColor: "rgba(51, 225, 237, 1)",
        borderWidth: 2,
        pointBackgroundColor: "rgba(51, 225, 237, 1)",
        pointBorderColor: "#fff",
        pointHoverBackgroundColor: "#fff",
        pointHoverBorderColor: "rgba(51, 225, 237, 1)",
      },
    ],
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div className="overflow-hidden sm:rounded-lg">
            <div className="p-6">
              <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
                {/* 左側: レーダーチャート */}
                <div className="flex justify-center items-start">
                  {lab.reviews && lab.reviews.length > 0 ? (
                    <div className="w-full max-w-sm">
                      <Radar data={chartData} options={chartOptions} />
                    </div>
                  ) : (
                    <p className="text-center text-gray-500">
                      まだレビューがありません
                    </p>
                  )}
                </div>

                {/* 右側: 総合評価と研究室概要 */}
                <div>
                  {/* 総合評価 */}
                  <div className="mb-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      総合評価
                    </h2>
                    {lab.reviews && lab.reviews.length > 0 ? (
                      <div className="flex items-center gap-2">
                        <StarRating rating={overallAverage || 0} />
                        <span className="text-sm text-[#F4BB42]">
                          {overallAverage ? Number(overallAverage).toFixed(2) : "0.00"}
                        </span>
                      </div>
                    ) : (
                      <p className="text-sm text-gray-500">まだ評価がありません</p>
                    )}
                  </div>

                  {/* 研究室概要 */}
                  <h2 className="text-base font-semibold text-gray-800 mb-2">
                    研究室概要
                  </h2>
                  <p className="text-sm text-gray-600 whitespace-pre-wrap leading-tight">
                    {lab.description || "概要はまだ登録されていません"}
                  </p>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
};

export default Show;

フォーマット用の関数を切り出す

先ほども作った、小数点以下2桁をフォーマットする処理ですが、内容が前回作ったLabCardと共通しているので、関数として外部に切り出して、インポートして使えるように修正したいと思います。

\src\resources\js\utils\formatRating.js
/**
 * 評価値を小数点以下2桁にフォーマット
 * @param {number|null} value - 評価値
 * @param {string} fallback - 値がnullの場合の代替文字列(デフォルト: null)
 * @returns {string|null} フォーマットされた評価値
 */
export const formatRating = (value, fallback = null) => {
  return value != null ? Number(value).toFixed(2) : fallback;
};

\project-root\src\resources\js\Pages\Lab\Show.jsx
import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import StarRating from "@/Components/Lab/Star/StarRating";
import { formatRating } from "@/utils/formatRating";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

const Show = ({ lab, averagePerItem, overallAverage }) => {
  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: [
      {
        label: "全投稿者の平均評価",
        data: averageRatings,
        backgroundColor: "rgba(51, 225, 237, 0.2)",
        borderColor: "rgba(51, 225, 237, 1)",
        borderWidth: 2,
        pointBackgroundColor: "rgba(51, 225, 237, 1)",
        pointBorderColor: "#fff",
        pointHoverBackgroundColor: "#fff",
        pointHoverBorderColor: "rgba(51, 225, 237, 1)",
      },
    ],
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div className="overflow-hidden sm:rounded-lg">
            <div className="p-6">
              <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
                {/* 左側: レーダーチャート */}
                <div className="flex justify-center items-start">
                  {lab.reviews && lab.reviews.length > 0 ? (
                    <div className="w-full max-w-sm">
                      <Radar data={chartData} options={chartOptions} />
                    </div>
                  ) : (
                    <p className="text-center text-gray-500">
                      まだレビューがありません
                    </p>
                  )}
                </div>

                {/* 右側: 総合評価と研究室概要 */}
                <div>
                  {/* 総合評価 */}
                  <div className="mb-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      総合評価
                    </h2>
                    {lab.reviews && lab.reviews.length > 0 ? (
                      <div className="flex items-center gap-2">
                        <StarRating rating={overallAverage || 0} />
                        <span className="text-sm text-[#F4BB42]">
                          {formatRating(overallAverage, "0.00")}
                        </span>
                      </div>
                    ) : (
                      <p className="text-sm text-gray-500">まだ評価がありません</p>
                    )}
                  </div>

                  {/* 研究室概要 */}
                  <h2 className="text-base font-semibold text-gray-800 mb-2">
                    研究室概要
                  </h2>
                  <p className="text-sm text-gray-600 whitespace-pre-wrap leading-tight">
                    {lab.description || "概要はまだ登録されていません"}
                  </p>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
};

export default Show;
\project-root\src\resources\js\Components\Lab\LabCard.jsx
import { router } from "@inertiajs/react";
import RankingBadge from '../../Components/Lab/RankingBadge';
import StarRating from "./Star/StarRating";
import { formatRating } from "@/utils/formatRating";

/**
 * ソート条件に対応する評価値を取得
 * @param {Object} lab - 研究室オブジェクト
 * @param {string} sort - ソート条件のキー
 * @returns {number|null} 評価値
 */
const getRatingValue = (lab, sort) => {
  if (sort === 'reviews_count') {
    return null; // レビュー数の場合は星評価を表示しない
  }
  
  const ratingMap = {
    overall: lab.overall_avg,
    mentorship_style: lab.avg_mentorship_style,
    lab_atmosphere: lab.avg_lab_atmosphere,
    achievement_activity: lab.avg_achievement_activity,
    constraint_level: lab.avg_constraint_level,
    facility_quality: lab.avg_facility_quality,
    work_style: lab.avg_work_style,
    student_balance: lab.avg_student_balance,
  };
  
  return ratingMap[sort] ?? lab.overall_avg;
};

/**
 * 研究室カードコンポーネント
 * @param {Object} props - コンポーネントのprops
 * @param {Object} props.lab - 研究室オブジェクト
 * @param {string} props.query - 検索クエリ文字列
 * @param {string} props.sort - ソート条件
 * @returns {JSX.Element} コンポーネントのJSX
 */
const LabCard = ({ lab, query, sort = 'overall' }) => {
  // ソート条件に応じた評価値を取得
  const ratingValue = getRatingValue(lab, sort);
  const formattedReview = formatRating(ratingValue);

  // レビュー数を取得(reviews_countまたはreviewsの配列長)
  const reviewCount = lab.reviews_count ?? lab.reviews?.length ?? 0;

  return (
    <div
      className="bg-[#EEF7FB] rounded-lg shadow-md px-4 py-3 hover:shadow-lg transition-shadow cursor-pointer flex items-start gap-4 relative"
      onClick={() => router.get(route("labs.show", { lab: lab.id, query }))}
    >
      <div className="absolute top-1 left-1">
        <RankingBadge rank={lab.rank} className="flex-shrink-0 text-3xl" />
      </div>
      <div className="ml-6">
        <span className="text-2xl font-bold text-[#747D8C]">
          {lab.name}
        </span>
        {formattedReview && (
          <div className="flex items-center gap-2 mt-1">
            <StarRating rating={ratingValue} />
            <span className="text-sm text-[#F4BB42]">
              {formattedReview}
            </span>
          </div>
        )}
      </div>
      {/* 右下にレビュー数を表示 */}
      <div className="absolute bottom-2 right-3">
        <span className="text-sm text-[#747D8C]">
          {reviewCount}件のレビュー
        </span>
      </div>
    </div>
  );
}

export default LabCard;

前回作成したページにも問題がないか確認してみましょう!
image.png

また、レビューの投稿数も表示しておきましょう。

\project-root\src\resources\js\Pages\Lab\Show.jsx
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      総合評価({lab.reviews?.length || 0})
                    </h2>

こんな感じになっていればOKです!
image.png

研究室URLを追加

研究室のホームページを追加しましょう。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import StarRating from "@/Components/Lab/Star/StarRating";
import { formatRating } from "@/utils/formatRating";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

const Show = ({ lab, averagePerItem, overallAverage }) => {
  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: [
      {
        label: "全投稿者の平均評価",
        data: averageRatings,
        backgroundColor: "rgba(51, 225, 237, 0.2)",
        borderColor: "rgba(51, 225, 237, 1)",
        borderWidth: 2,
        pointBackgroundColor: "rgba(51, 225, 237, 1)",
        pointBorderColor: "#fff",
        pointHoverBackgroundColor: "#fff",
        pointHoverBorderColor: "rgba(51, 225, 237, 1)",
      },
    ],
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div className="overflow-hidden sm:rounded-lg">
            <div className="p-6">
              <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
                {/* 左側: レーダーチャート */}
                <div className="flex justify-center items-start">
                  {lab.reviews && lab.reviews.length > 0 ? (
                    <div className="w-full max-w-sm">
                      <Radar data={chartData} options={chartOptions} />
                    </div>
                  ) : (
                    <p className="text-center text-gray-500">
                      まだレビューがありません
                    </p>
                  )}
                </div>

                {/* 右側: 総合評価と研究室概要 */}
                <div>
                  {/* 総合評価 */}
                  <div className="mb-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      総合評価({lab.reviews?.length || 0})
                    </h2>
                    {lab.reviews && lab.reviews.length > 0 ? (
                      <div className="flex items-center gap-2">
                        <StarRating rating={overallAverage || 0} />
                        <span className="text-sm text-[#F4BB42]">
                          {formatRating(overallAverage, "0.00")}
                        </span>
                      </div>
                    ) : (
                      <p className="text-sm text-gray-500">まだ評価がありません</p>
                    )}
                  </div>

                  {/* 研究室概要 */}
                  <h2 className="text-base font-semibold text-gray-800 mb-2">
                    研究室概要
                  </h2>
                  <p className="text-sm text-gray-600 whitespace-pre-wrap leading-tight">
                    {lab.description || "概要はまだ登録されていません"}
                  </p>

                  {/* 研究室ページ */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      研究室ページ
                    </h2>
                    {lab.url ? (
                      <a
                        href={lab.url}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="text-sm text-blue-600 hover:text-blue-800 hover:underline break-all"
                      >
                        {lab.url}
                      </a>
                    ) : (
                      <p className="text-sm text-gray-500">URLはまだ登録されていません</p>
                    )}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
};

export default Show;

image.png

教授情報を追加

教授の名前を追加します。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import StarRating from "@/Components/Lab/Star/StarRating";
import { formatRating } from "@/utils/formatRating";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

const Show = ({ lab, averagePerItem, overallAverage }) => {
  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: [
      {
        label: "全投稿者の平均評価",
        data: averageRatings,
        backgroundColor: "rgba(51, 225, 237, 0.2)",
        borderColor: "rgba(51, 225, 237, 1)",
        borderWidth: 2,
        pointBackgroundColor: "rgba(51, 225, 237, 1)",
        pointBorderColor: "#fff",
        pointHoverBackgroundColor: "#fff",
        pointHoverBorderColor: "rgba(51, 225, 237, 1)",
      },
    ],
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div className="overflow-hidden sm:rounded-lg">
            <div className="p-6">
              <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
                {/* 左側: レーダーチャート */}
                <div className="flex justify-center items-start">
                  {lab.reviews && lab.reviews.length > 0 ? (
                    <div className="w-full max-w-sm">
                      <Radar data={chartData} options={chartOptions} />
                    </div>
                  ) : (
                    <p className="text-center text-gray-500">
                      まだレビューがありません
                    </p>
                  )}
                </div>

                {/* 右側: 総合評価と研究室概要 */}
                <div>
                  {/* 総合評価 */}
                  <div className="mb-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      総合評価({lab.reviews?.length || 0})
                    </h2>
                    {lab.reviews && lab.reviews.length > 0 ? (
                      <div className="flex items-center gap-2">
                        <StarRating rating={overallAverage || 0} />
                        <span className="text-sm text-[#F4BB42]">
                          {formatRating(overallAverage, "0.00")}
                        </span>
                      </div>
                    ) : (
                      <p className="text-sm text-gray-500">まだ評価がありません</p>
                    )}
                  </div>

                  {/* 研究室概要 */}
                  <h2 className="text-base font-semibold text-gray-800 mb-2">
                    研究室概要
                  </h2>
                  <p className="text-sm text-gray-600 whitespace-pre-wrap leading-tight">
                    {lab.description || "概要はまだ登録されていません"}
                  </p>

                  {/* 研究室ページ */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      研究室ページ
                    </h2>
                    {lab.url ? (
                      <a
                        href={lab.url}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="text-sm text-blue-600 hover:text-blue-800 hover:underline break-all"
                      >
                        {lab.url}
                      </a>
                    ) : (
                      <p className="text-sm text-gray-500">URLはまだ登録されていません</p>
                    )}
                  </div>

                  {/* 教授 */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      教授
                    </h2>
                    <p className="text-sm text-gray-600">
                      {lab.professor_name ? `${lab.professor_name} 先生` : "教授名はまだ登録されていません"}
                    </p>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
};

export default Show;

男女比を追加する

男女比はバーで割合を表示させます。

\project-root\src\resources\js\Pages\Lab\Show.jsx
import { Head } from "@inertiajs/react";
import AppLayout from "@/Layouts/AppLayout";
import StarRating from "@/Components/Lab/Star/StarRating";
import { formatRating } from "@/utils/formatRating";
import {
  Chart as ChartJS,
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend,
} from "chart.js";
import { Radar } from "react-chartjs-2";

// Chart.jsのコンポーネントを登録
ChartJS.register(
  RadialLinearScale,
  PointElement,
  LineElement,
  Filler,
  Tooltip,
  Legend
);

const Show = ({ lab, averagePerItem, overallAverage }) => {
  // 7つの評価指標のラベル
  const labels = [
    "指導スタイル",
    "雰囲気・文化",
    "成果・活動",
    "拘束度",
    "設備",
    "働き方",
    "人数バランス",
  ];

  // LabControllerから渡された平均評価データを配列に変換
  const averageRatings = averagePerItem
    ? [
        averagePerItem.mentorship_style || 0,
        averagePerItem.lab_atmosphere || 0,
        averagePerItem.achievement_activity || 0,
        averagePerItem.constraint_level || 0,
        averagePerItem.facility_quality || 0,
        averagePerItem.work_style || 0,
        averagePerItem.student_balance || 0,
      ]
    : [0, 0, 0, 0, 0, 0, 0];

  // レーダーチャートのデータ
  const chartData = {
    labels: labels,
    datasets: [
      {
        label: "全投稿者の平均評価",
        data: averageRatings,
        backgroundColor: "rgba(51, 225, 237, 0.2)",
        borderColor: "rgba(51, 225, 237, 1)",
        borderWidth: 2,
        pointBackgroundColor: "rgba(51, 225, 237, 1)",
        pointBorderColor: "#fff",
        pointHoverBackgroundColor: "#fff",
        pointHoverBorderColor: "rgba(51, 225, 237, 1)",
      },
    ],
  };

  // レーダーチャートのオプション
  const chartOptions = {
    responsive: true,
    maintainAspectRatio: true,
    scales: {
      r: {
        angleLines: {
          display: true,
        },
        suggestedMin: 0,
        suggestedMax: 5,
        ticks: {
          stepSize: 1,
          font: {
            size: 12,
          },
        },
        pointLabels: {
          font: {
            size: 14,
          },
        },
      },
    },
    plugins: {
      legend: {
        position: "top",
      },
      tooltip: {
        callbacks: {
          label: (context) => {
            return `${context.dataset.label}: ${context.raw.toFixed(2)}`;
          },
        },
      },
    },
  };

  return (
    <AppLayout title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`}>
      <Head title={`${lab.faculty.university.name} ${lab.faculty.name} ${lab.name}`} />

      <div className="py-12">
        <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
          <div className="overflow-hidden sm:rounded-lg">
            <div className="p-6">
              <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
                {/* 左側: レーダーチャート */}
                <div className="flex justify-center items-start">
                  {lab.reviews && lab.reviews.length > 0 ? (
                    <div className="w-full max-w-sm">
                      <Radar data={chartData} options={chartOptions} />
                    </div>
                  ) : (
                    <p className="text-center text-gray-500">
                      まだレビューがありません
                    </p>
                  )}
                </div>

                {/* 右側: 総合評価と研究室概要 */}
                <div>
                  {/* 総合評価 */}
                  <div className="mb-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      総合評価({lab.reviews?.length || 0})
                    </h2>
                    {lab.reviews && lab.reviews.length > 0 ? (
                      <div className="flex items-center gap-2">
                        <StarRating rating={overallAverage || 0} />
                        <span className="text-sm text-[#F4BB42]">
                          {formatRating(overallAverage, "0.00")}
                        </span>
                      </div>
                    ) : (
                      <p className="text-sm text-gray-500">まだ評価がありません</p>
                    )}
                  </div>

                  {/* 研究室概要 */}
                  <h2 className="text-base font-semibold text-gray-800 mb-2">
                    研究室概要
                  </h2>
                  <p className="text-sm text-gray-600 whitespace-pre-wrap leading-tight">
                    {lab.description || "概要はまだ登録されていません"}
                  </p>

                  {/* 研究室ページ */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      研究室ページ
                    </h2>
                    {lab.url ? (
                      <a
                        href={lab.url}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="text-sm text-blue-600 hover:text-blue-800 hover:underline break-all"
                      >
                        {lab.url}
                      </a>
                    ) : (
                      <p className="text-sm text-gray-500">URLはまだ登録されていません</p>
                    )}
                  </div>

                  {/* 教授 */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      教授  
                    </h2>
                    <p className="text-sm text-gray-600">
                      {lab.professor_name ? `${lab.professor_name} 先生` : "教授名はまだ登録されていません"}
                    </p>
                  </div>

                  {/* 男女比 */}
                  <div className="mt-4">
                    <h2 className="text-base font-semibold text-gray-800 mb-2">
                      男女比{(lab.gender_ratio_male != null && lab.gender_ratio_female != null) && `(${lab.gender_ratio_male}:${lab.gender_ratio_female})`}
                    </h2>
                    {(lab.gender_ratio_male != null && lab.gender_ratio_female != null) ? (
                      <div className="flex w-full h-6 rounded overflow-hidden text-sm text-white font-medium">
                        {lab.gender_ratio_male > 0 && (
                          <div
                            className="flex items-center justify-center"
                            style={{
                              backgroundColor: "#7BB3CE",
                              width: `${(lab.gender_ratio_male / (lab.gender_ratio_male + lab.gender_ratio_female)) * 100}%`,
                            }}
                          ></div>
                        )}
                        {lab.gender_ratio_female > 0 && (
                          <div
                            className="flex items-center justify-center"
                            style={{
                              backgroundColor: "#E89EB9",
                              width: `${(lab.gender_ratio_female / (lab.gender_ratio_male + lab.gender_ratio_female)) * 100}%`,
                            }}
                          ></div>
                        )}
                      </div>
                    ) : (
                      <p className="text-sm text-gray-500">男女比はまだ登録されていません</p>
                    )}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
};

export default Show;

今回はここまでとします!

今回は記事を書く前から長くなるなと思っていました。

続きは、後編でお会いしましょう!

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

現在、まったく再生されておらず、落ち込みそうなので、見てくださる方はぜひ高評価を教えもらえると励みになると思います。"(-""-)"

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?