Next.js とSupabaseで求人マッチングアプリを作る⑫~応募・スカウト機能の実装~

いよいよマッチングアプリ開発も終盤になってきました!
今回はマッチングアプリの肝になる部分、求職者から応募、企業からスカウトができる機能を作成します。

目次

Supabaseの設定

今回応募・スカウトをするにあたり、必要なテーブルがいくつかあるため、そちらを作成します。

m2m_job_seeker_job

求職者と求人を結び付ける`m2m_job_seeker_job`テーブルを作成します。
添付画像のような設定で、列を作成しましょう。

追加の列はすべてnullを許容せず、
job_seeker_idは`mst_job_seeker`の`user_uid`を参照してください。
また、job_idは`trn_job`の`id`を参照してください。

m2m_job_seeker_job_status

`m2m_job_seeker_job`の状況を確認するステータス(面接前、不合格、選考中、内定、辞退)を定義するテーブルを作成します。
添付画像のような設定で列を作成しましょう。

追加の列はoffer_date以外すべてnullを許容せず、
job_seeker_job_idは`m2m_job_seeker_job`の`id`を参照してください。

ポリシー設定

ポリシーは双方ともに`ALL`かつtrueで作成します。

TypeScriptの型生成

Next.jsのプロジェクト直下でSupabaseにログインします。

npx supabase login

その後、下記を実行して今回のテーブルの追加を反映しましょう。

npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts

関数とトリガー作成

`m2m_job_seeker_job`テーブルにデータが追加されたとき、`m2m_job_seeker_job_status`の行を自動で追加するために関数とトリガーを作成します。
supabaseの`Query Editor`から下記の関数作成用のSQLを作成して実行してください。

CREATE OR REPLACE FUNCTION create_job_seeker_job_status()
RETURNS TRIGGER
language plpgsql
security definer set search_path = public
AS $$
BEGIN
    INSERT INTO m2m_job_seeker_job_status(job_seeker_job_id)
    VALUES (NEW.id);
    RETURN NEW;
END;
$$

次にトリガー用のSQLを作成します。

CREATE TRIGGER trigger_create_job_seeker_job_status
AFTER INSERT ON public.m2m_job_seeker_job
FOR EACH ROW
EXECUTE FUNCTION create_job_seeker_job_status();

これでSupabase側の対応は完了です。

Next.jsの実装

まずは企業側のスカウト機能を作成していきます。

components/company/JobSeekerDetail.tsx

企業側でスカウトができるよう`<ScoutButton />`を設置します。

"use client";
import { createClient } from "@/utils/supabase/client";
import { useState, useEffect } from "react";
import { Database } from "@/types/supabase";
import DetailList from "@/components/common/DetailList";
import ScoutButton from "@/components/company/jobseekerdetail/ScoutButton";

type Props = {
  jobseekerid: string | null;
};
export default function JobSeekerDetail({ jobseekerid }: Props) {
  const supabase = createClient();

  // フォームデータを保持する
  const [lastname, setLastname] = useState("");
  const [firstname, setFirstname] = useState("");
  const [middlename, setMiddlename] = useState<string | null>(null);
  const [gender, setGender] = useState("");
  const [birthday, setBirthday] = useState("");
  const [zipcode, setZipcode] = useState<string | null>(null);
  const [address1, setAddress1] = useState<string | null>(null);
  const [address2, setAddress2] = useState<string | null>(null);
  const [phone, setPhone] = useState<string | null>(null);
  const [email, setEmail] = useState("");
  const [nationality_name, setNationality_name] = useState<string | null>(null);
  const [current_annual_income, setCurrent_annual_income] = useState<
    number | null
  >(null);
  const [desired_annual_income, setDesired_annual_income] = useState<
    number | null
  >(null);
  const [spouse, setSpouse] = useState<string | null>(null);
  const [desired_occupation_name_1, setDesired_occupation_name_1] = useState<
    string | null
  >(null);
  const [desired_occupation_name_2, setDesired_occupation_name_2] = useState<
    string | null
  >(null);
  const [desired_occupation_name_3, setDesired_occupation_name_3] = useState<
    string | null
  >(null);
  const [desired_change_job_date, setDesired_change_job_date] = useState<
    string | null
  >(null);
  const [residence_qualification_name, setResidence_qualification_name] =
    useState<string | null>(null);
  const [residence_qualification_expired, setResidence_qualification_expired] =
    useState<string | null>(null);
  const [
    residence_qualification_front_image_url,
    setResidence_qualification_front_image_url,
  ] = useState<string | null>(null);
  const [
    residence_qualification_back_image_url,
    setResidence_qualification_back_image_url,
  ] = useState<string | null>(null);
  const [profile_image_url, setProfile_image_url] = useState<string | null>(
    null
  );
  const [resume_file_url, setResume_file_url] = useState<string | null>(null);
  const [resume_file_name, setResume_file_name] = useState<string | null>(null);

  useEffect(() => {
    setData();
  }, []);

  const setData = async () => {
    if (jobseekerid === "") {
      return;
    }
    const { data, error } = await supabase
      .from("mst_job_seeker")
      .select()
      .eq("user_uid", jobseekerid);

    if (data != null && data?.length != 0) {
      const job_seeker_data: Database["public"]["Tables"]["mst_job_seeker"]["Row"] =
        data[0];
      setLastname(job_seeker_data.last_name);
      setFirstname(job_seeker_data.first_name);
      if (job_seeker_data.middle_name != null) {
        setMiddlename(job_seeker_data.middle_name);
      }
      setGender("" + job_seeker_data.gender);
      setBirthday(job_seeker_data.birthday);
      if (job_seeker_data.zipcode != null) {
        setZipcode(job_seeker_data.zipcode);
      }
      if (job_seeker_data.address1 != null) {
        setAddress1(job_seeker_data.address1);
      }
      if (job_seeker_data.address2 != null) {
        setAddress2(job_seeker_data.address2);
      }
      if (job_seeker_data.phone != null) {
        setPhone(job_seeker_data.phone);
      }
      setEmail(job_seeker_data.email);
      if (job_seeker_data.nationality_id != null) {
        const name = await getNationalityName(job_seeker_data.nationality_id);
        setNationality_name(name);
      }
      if (job_seeker_data.current_annual_income != null) {
        setCurrent_annual_income(job_seeker_data.current_annual_income);
      }
      if (job_seeker_data.desired_annual_income != null) {
        setDesired_annual_income(job_seeker_data.desired_annual_income);
      }
      if (job_seeker_data.spouse != null) {
        setSpouse("" + job_seeker_data.spouse);
      }
      if (job_seeker_data.desired_occupation_id_1 != null) {
        const name = await getOccupationName(
          job_seeker_data.desired_occupation_id_1
        );
        setDesired_occupation_name_1(name);
      }
      if (job_seeker_data.desired_occupation_id_2 != null) {
        const name = await getOccupationName(
          job_seeker_data.desired_occupation_id_2
        );
        setDesired_occupation_name_2(name);
      }
      if (job_seeker_data.desired_occupation_id_3 != null) {
        const name = await getOccupationName(
          job_seeker_data.desired_occupation_id_3
        );
        setDesired_occupation_name_3(name);
      }
      if (job_seeker_data.desired_change_job_date != null) {
        setDesired_change_job_date(job_seeker_data.desired_change_job_date);
      }
      if (job_seeker_data.residence_qualification_id != null) {
        const name = await getResidenceQualificationName(
          job_seeker_data.residence_qualification_id
        );
        setResidence_qualification_name(name);
      }
      if (job_seeker_data["residence qualification_expired"] != null) {
        setResidence_qualification_expired(
          job_seeker_data["residence qualification_expired"]
        );
      }
      if (job_seeker_data["residence qualification_front_image_url"] != null) {
        setResidence_qualification_front_image_url(
          job_seeker_data["residence qualification_front_image_url"]
        );
      }
      if (job_seeker_data["residence qualification_back_image_url"] != null) {
        setResidence_qualification_back_image_url(
          job_seeker_data["residence qualification_back_image_url"]
        );
      }
      if (job_seeker_data.profile_image_url != null) {
        setProfile_image_url(job_seeker_data.profile_image_url);
      }
      if (job_seeker_data.resume_file_url != null) {
        setResume_file_url(job_seeker_data.resume_file_url);
      }
      if (job_seeker_data.resume_file_name != null) {
        setResume_file_name(job_seeker_data.resume_file_name);
      }
    }
  };

  const getNationalityName = async (id: string) => {
    const { data, error } = await supabase
      .from("mst_nationality")
      .select()
      .eq("id", id);
    if (error) {
      console.log(error);
      return;
    }
    return data[0].nationality;
  };

  const getOccupationName = async (id: string) => {
    const { data, error } = await supabase
      .from("mst_occupation")
      .select()
      .eq("id", id);
    if (error) {
      console.log(error);
      return;
    }

    return data[0].occupation;
  };

  const getResidenceQualificationName = async (id: string) => {
    const { data, error } = await supabase
      .from("mst_residence_qualification")
      .select()
      .eq("id", id);
    if (error) {
      console.log(error);
      return;
    }

    return data[0].residence_qualification;
  };

  return (
    <div className="flex-1 flex flex-col w-full py-8 max-w-xl justify-center gap-2 animate-in">
      <h1>求職者詳細</h1>
      <ul>
        <DetailList title={"姓"} value={lastname}/>
        <DetailList title={"名"} value={firstname}/>
        <DetailList title={"ミドルネーム"} value={middlename}/>
        <DetailList title={"性別"} value={gender == "0" ? "女性" : gender == "1" ? "男性" : "その他"}/>
        <DetailList title={"誕生日"} value={birthday}/>
        <DetailList title={"郵便番号"} value={zipcode}/>
        <DetailList title={"住所1"} value={address1}/>
        <DetailList title={"住所2"} value={address2}/>
        <DetailList title={"電話番号"} value={phone}/>
        <DetailList title={"メールアドレス"} value={email}/>
        <DetailList title={"国籍"} value={nationality_name}/>
        <DetailList title={"現在の年収"} value={current_annual_income + "万円"}/>
        <DetailList title={"希望年収"} value={desired_annual_income + "万円"}/>
        <DetailList title={"配偶者"} value={spouse === "true" ? "あり" : "なし"}/>
        <DetailList title={"希望職種1"} value={desired_occupation_name_1}/>
        <DetailList title={"希望職種2"} value={desired_occupation_name_2}/>
        <DetailList title={"希望職種3"} value={desired_occupation_name_3}/>
        <DetailList title={"転職希望日"} value={desired_change_job_date}/>
        <DetailList title={"在留資格"} value={residence_qualification_name}/>
        <DetailList title={"在留資格期限"} value={residence_qualification_expired}/>
        {/*<DetailList title={"在留資格カード表のファイルURL"} value={residence_qualification_front_image_url}/>*/}
        {/*<DetailList title={"在留資格カード裏のファイルURL"} value={residence_qualification_back_image_url}/>*/}
        {/*<DetailList title={"プロフィール画像のファイルURL"} value={profile_image_url}/>*/}
        <DetailList title={"履歴書のファイルURL"} value={resume_file_url}/>
        <DetailList title={"履歴書のファイル名"} value={resume_file_name}/>

      </ul>
      {jobseekerid ? (<ScoutButton jobseekerid={jobseekerid}></ScoutButton>) : null}
    </div>
  );
}

components/company/MyJobList.tsx

MyjobListのデータ取得処理を共通化したのでリファクタしておきます。

"use client";
import {createClient} from "@/utils/supabase/client";
import {useState, useEffect} from "react";
import {Database} from "@/types/supabase";
import Link from "next/link";
import {redirect} from "next/navigation";
import {getFixedJobData} from "@/components/jobseeker/JobDataUtil";

/**
 * 自社の求人一覧。作成、編集につなげることができる
 */
export default function MyJobList() {
    const supabase = createClient();
    const [jobData, setJobData] = useState<any[]>([]);

    useEffect(() => {
        getJobData();
    }, []);

    const getJobData = async () => {
        const {
            data: {user},
        } = await supabase.auth.getUser();

        if (!user) {
            return redirect("/login");
        }

        const {data, error} = await supabase.from("trn_job").select().eq("company_uid", user.id);
        if (error) {
            console.log(error);
            return;
        }
        const tmp_data: Database["public"]["Tables"]["trn_job"]["Row"][] = data as Database["public"]["Tables"]["trn_job"]["Row"][];

        const fixedData = await getFixedJobData(tmp_data)
        setJobData(fixedData)
    };

    return (
        <div className="flex-1 flex flex-col w-full py-8 max-w-xl justify-center gap-2 animate-in">
            <h1 className="font-bold text-2xl mb-2">自社の求人一覧</h1>
            <Link
                href={"/myjobedit?jobid="}
                className="bg-white border border-gray-300 rounded-full px-2 py-1 w-32 justify-center flex items-center"
            >
                新規求人作成
            </Link>
            <ul>
                {jobData.map((item) => (
                    <li
                        key={item.id}
                        className="mb-5"
                    >
                        <Link href={"/myjobedit?jobid=" + item.id}
                              className="bg-white rounded-md block w-full hover:bg-gray-300 transition-all p-4 border-b border-gray-300">
                            <h2 className="text-lg text-blue-700 inline-block border-b border-blue-700 mb-4">{item.name}</h2>
                            <div className="flex flex-wrap mb-2">
                                <div className="flex content-center">
                                    <span className="material-symbols-outlined">location_on</span>
                                    <p className="ml-1">
                                        {item.work_location}
                                    </p>
                                </div>

                                <div className="ml-4 flex content-center">
                                    <span className="material-symbols-outlined">badge</span>
                                    <p className="ml-1">
                                        {item.employment_class == 1
                                            ? "正社員"
                                            : item.employment_class == 2
                                                ? "契約社員"
                                                : "アルバイト"}
                                    </p>
                                </div>
                                <div className="ml-4 flex content-center">
                                    <span className="material-symbols-outlined">attach_money</span>
                                    <p className="ml-1">
                                        {item.annual_income}万円
                                    </p>
                                </div>
                                <div className="ml-4 flex content-center">
                                    <span className="material-symbols-outlined">work</span>
                                    <p className="ml-1">
                                        {item.occupation}
                                    </p>
                                </div>
                            </div>
                            <dl>
                                <div className="flex content-center mb-2">
                                    <dt className="font-bold min-w-20">応募資格</dt>
                                    <span>:</span>
                                    <dd>{item.qualification}</dd>
                                </div>
                                <div className="flex content-center">
                                    <dt className="font-bold min-w-20">必須スキル</dt>
                                    <span>:</span>
                                    <dd>{item.required_skills}</dd>
                                </div>
                            </dl>
                        </Link>
                    </li>
                ))}
            </ul>
        </div>
    );
}

components/company/jobseekerdetail/ScoutButton.tsx

スカウトボタンのコンポーネントを作成します。

"use client"
import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import ScoutJobList from "@/components/company/jobseekerdetail/ScoutJobList";

type Props = {
    jobseekerid: string
}
export default function ScoutButton({jobseekerid}: Props) {
    const [showFilter, setShowFilter] = useState<boolean>(false)

    useEffect(() => {
    }, []);

    const onScout = async () => {
        setShowFilter(true)
    }
    return (<>

            <button onClick={onScout}
                    className="bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white">スカウト
            </button>
            <div
                className={showFilter ? ("fixed z-10 bg-gray-100 w-full overflow-y-scroll h-full p-10") : ("hidden fixed z-10 bg-gray-100 w-full overflow-y-scroll h-full p-10")}>
                <div className="text-right">
                    <button onClick={() => setShowFilter(false)}>
                        <span className="material-symbols-outlined">
                            close
                        </span>
                    </button>
                </div>
                <ScoutJobList jobseekerid={jobseekerid}/>
            </div>
        </>
    )
}

components/company/jobseekerdetail/ScoutJobList.tsx

企業側のスカウトの際は、求職者と求人を結び付けるために求人リストから選択できる必要があるため、そのためのリスト画面を作成します。

"use client";
import {createClient} from "@/utils/supabase/client";
import {useState, useEffect} from "react";
import {Database} from "@/types/supabase";
import {redirect} from "next/navigation";
import {getFixedJobData} from "@/components/jobseeker/JobDataUtil";

type Props = {
    jobseekerid: string
}
export default function ScoutJobList({jobseekerid}: Props) {
    const supabase = createClient();
    const [jobData, setJobData] = useState<any[]>([]);

    useEffect(() => {
        getJobData();
    }, []);

    const getScoutedJobs = async () => {
        const {data, error} = await supabase
            .from('m2m_job_seeker_job')
            .select()
            .eq("job_seeker_id", jobseekerid)

        if (error) return [];

        return data as Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][];
    }

    const getJobData = async () => {
        const scoutedJobs = await getScoutedJobs();
        console.log(scoutedJobs)

        const {
            data: {user},
        } = await supabase.auth.getUser();

        if (!user) {
            return redirect("/login");
        }

        const {data, error} = await supabase.from("trn_job").select().eq("company_uid", user.id);
        if (error) {
            console.log(error);
            return;
        }
        const tmp_data: Database["public"]["Tables"]["trn_job"]["Row"][] = data as Database["public"]["Tables"]["trn_job"]["Row"][];
        const notScoutedJobs: Database["public"]["Tables"]["trn_job"]["Row"][]  = []
        for (let i = 0; i < tmp_data.length; i++) {
            let isScouted = false;
            for (let j = 0; j < scoutedJobs.length; j++) {
                if (scoutedJobs[j]["job_id"] === tmp_data[i]["id"]) {
                    isScouted = true;
                    break;
                }
            }
            if (!isScouted) {
                notScoutedJobs.push(tmp_data[i]);
            }
        }

        const fixedData = await getFixedJobData(notScoutedJobs)

        setJobData(fixedData);
    };

    const onScout = async (jobid: string) => {
        const result = window.confirm("この求人で求職者をスカウトしますか?");
        if (!result) return

        const {error} = await supabase
            .from('m2m_job_seeker_job')
            .insert({job_seeker_id: jobseekerid, job_id: jobid, type: "recruit"})

        if (error) {
            console.log(error)
            return
        }

        await getJobData();
    }

    return (
        <div className="flex-1 flex flex-col w-full py-8 max-w-xl justify-center gap-2 animate-in">
            <h1 className="font-bold text-2xl mb-2">スカウトを送る求人を選択</h1>
            <ul>
                {jobData.map((item) => (
                    <li
                        key={item.id}
                        className="mb-5"
                    >
                        <button
                            onClick={() => onScout(item.id)}
                              className="text-left bg-white rounded-md block w-full hover:bg-gray-300 transition-all p-4 border-b border-gray-300">
                            <h2 className="text-lg text-blue-700 inline-block border-b border-blue-700 mb-4">{item.name}</h2>
                            <div className="flex flex-wrap mb-2">
                                <div className="flex content-center">
                                    <span className="material-symbols-outlined">location_on</span>
                                    <p className="ml-1">
                                        {item.work_location}
                                    </p>
                                </div>

                                <div className="ml-4 flex content-center">
                                    <span className="material-symbols-outlined">badge</span>
                                    <p className="ml-1">
                                        {item.employment_class == 1
                                            ? "正社員"
                                            : item.employment_class == 2
                                                ? "契約社員"
                                                : "アルバイト"}
                                    </p>
                                </div>
                                <div className="ml-4 flex content-center">
                                    <span className="material-symbols-outlined">attach_money</span>
                                    <p className="ml-1">
                                        {item.annual_income}万円
                                    </p>
                                </div>
                                <div className="ml-4 flex content-center">
                                    <span className="material-symbols-outlined">work</span>
                                    <p className="ml-1">
                                        {item.occupation}
                                    </p>
                                </div>
                            </div>
                            <dl>
                                <div className="flex content-center mb-2">
                                    <dt className="font-bold min-w-20">応募資格</dt>
                                    <span>:</span>
                                    <dd>{item.qualification}</dd>
                                </div>
                                <div className="flex content-center">
                                    <dt className="font-bold min-w-20">必須スキル</dt>
                                    <span>:</span>
                                    <dd>{item.required_skills}</dd>
                                </div>
                            </dl>
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    );
}

次に求職者側の応募機能を作成します。

components/jobseeker/JobDetail.tsx

応募ボタン(`<ApplyButton />`)を追加しています。

"use client";
import {createClient} from "@/utils/supabase/client";
import {useState, useEffect} from "react";
import {Database} from "@/types/supabase";
import DetailList from "@/components/common/DetailList";
import ApplyButton from "@/components/jobseeker/jobdetail/ApplyButton";

type Props = {
    jobid: string | null;
};
export default function JobDetail({jobid}: Props) {
    const supabase = createClient();

    const [name, setName] = useState<string>("");
    const [description, setDescription] = useState<string>("");
    const [work_location, setWork_location] = useState<string>("");
    const [work_location_detail, setWork_location_detail] = useState<
        string | null
    >(null);
    const [working_hours, setWorking_hours] = useState<string>("");
    const [day_off, setDay_off] = useState<string>("");
    const [day_off_detail, setDay_off_detail] = useState<string | null>(null);
    const [employment_class, setEmployment_class] = useState<number>();
    const [annual_income, setAnnual_income] = useState<number>();
    const [annual_income_detail, setAnnual_income_detail] = useState<
        string | null
    >(null);
    const [treatment, setTreatment] = useState<string | null>(null);
    const [employee_benefits, setEmployee_benefits] = useState<string | null>(
        null
    );
    const [qualification, setQualification] = useState<string>("");
    const [required_skills, setRequired_skills] = useState<string | null>(null);
    const [skills, setSkills] = useState<string | null>(null);
    const [occupation_name, setOccupation_name] = useState<string>("");
    const [industry_name, setIndustry_name] = useState<string>("");
    const [job_image_url_1, setJob_image_url_1] = useState<string | null>(null);
    const [job_image_url_2, setJob_image_url_2] = useState<string | null>(null);
    const [job_image_url_3, setJob_image_url_3] = useState<string | null>(null);
    const [job_image_url_4, setJob_image_url_4] = useState<string | null>(null);
    const [job_image_url_5, setJob_image_url_5] = useState<string | null>(null);

    useEffect(() => {
        getJobDataFromId();
    }, []);

    const getJobDataFromId = async () => {
        if (jobid === "") {
            return;
        }

        const {data, error} = await supabase
            .from("trn_job")
            .select()
            .eq("id", jobid);
        if (error) {
            console.log(error);
            return;
        }

        console.log(data);

        const tmp_data: Database["public"]["Tables"]["trn_job"]["Row"] =
            data[0] as Database["public"]["Tables"]["trn_job"]["Row"];

        setName(tmp_data.name);
        setDescription(tmp_data.description);
        const work_location_name = await getWorklocationName(
            tmp_data.work_location
        );
        setWork_location(work_location_name);
        setWork_location_detail(tmp_data.work_location_detail);
        setWorking_hours(tmp_data.working_hours);
        const day_off_name = await getDayoffName(tmp_data.day_off);
        setDay_off(day_off_name);
        setDay_off_detail(tmp_data.day_off_detail);
        setEmployment_class(tmp_data.employment_class);
        setAnnual_income(tmp_data.annual_income);
        setAnnual_income_detail(tmp_data.annual_income_detail);
        setTreatment(tmp_data.treatment);
        setEmployee_benefits(tmp_data.employee_benefits);
        setQualification(tmp_data.qualification);
        setRequired_skills(tmp_data.required_skills);
        setSkills(tmp_data.skills);
        const occupation_name = await getOccupationName(tmp_data.occupation_id);
        setOccupation_name(occupation_name);
        const industry_name = await getIndustryName(tmp_data.industry_id);
        setIndustry_name(industry_name);
        setJob_image_url_1(tmp_data.job_image_url_1);
        setJob_image_url_2(tmp_data.job_image_url_2);
        setJob_image_url_3(tmp_data.job_image_url_3);
        setJob_image_url_4(tmp_data.job_image_url_4);
        setJob_image_url_5(tmp_data.job_image_url_5);
    };

    const getWorklocationName = async (id: string) => {
        const {data, error} = await supabase
            .from("mst_work_location")
            .select()
            .eq("id", id);
        if (error) {
            console.log(error);
            return;
        }

        return data[0].work_location;
    };

    const getDayoffName = async (id: string) => {
        const {data, error} = await supabase
            .from("mst_day_off")
            .select()
            .eq("id", id);
        if (error) {
            console.log(error);
            return;
        }

        return data[0].day_off;
    };

    const getOccupationName = async (id: string) => {
        const {data, error} = await supabase
            .from("mst_occupation")
            .select()
            .eq("id", id);
        if (error) {
            console.log(error);
            return;
        }

        return data[0].occupation;
    };

    const getIndustryName = async (id: string) => {
        const {data, error} = await supabase
            .from("mst_industry")
            .select()
            .eq("id", id);
        if (error) {
            console.log(error);
            return;
        }

        return data[0].industry;
    };

    return (
        <div className="flex-1 flex flex-col w-full py-8 max-w-xl justify-center gap-2 animate-in">
            <h1>求人詳細</h1>
            <ul>
                <DetailList title={"案件名"} value={name}/>
                <DetailList title={"概要"} value={description}/>
                <DetailList title={"勤務地"} value={work_location}/>
                <DetailList title={"勤務地詳細"} value={work_location_detail}/>
                <DetailList title={"勤務時間"} value={working_hours}/>
                <DetailList title={"休日"} value={day_off}/>
                <DetailList title={"休日詳細"} value={day_off_detail}/>
                <DetailList title={"雇用区分"} value={employment_class == 1
                    ? "正社員"
                    : employment_class == 2
                        ? "契約社員"
                        : "アルバイト"}/>
                <DetailList title={"想定年収"} value={annual_income + "万円"}/>
                <DetailList title={"想定年収詳細"} value={annual_income_detail}/>
                <DetailList title={"待遇"} value={treatment}/>
                <DetailList title={"福利厚生"} value={employee_benefits}/>
                <DetailList title={"応募資格"} value={qualification}/>
                <DetailList title={"必須スキル"} value={required_skills}/>
                <DetailList title={"歓迎スキル"} value={skills}/>
                <DetailList title={"職種"} value={occupation_name}/>
                <DetailList title={"業界"} value={industry_name}/>
                {/*<DetailList title={"募集画像1のURL"} value={job_image_url_1}/>*/}
                {/*<DetailList title={"募集画像2のURL"} value={job_image_url_2}/>*/}
                {/*<DetailList title={"募集画像3のURL"} value={job_image_url_3}/>*/}
                {/*<DetailList title={"募集画像4のURL"} value={job_image_url_4}/>*/}
                {/*<DetailList title={"募集画像5のURL"} value={job_image_url_5}/>*/}
            </ul>
            {jobid ? (<ApplyButton jobid={jobid!}></ApplyButton>
            ) : null}
        </div>
    );
}

components/jobseeker/jobdetail/ApplyButton.tsx

応募ボタンのコンポーネントです。

"use client"
import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";

type Props = {
    jobid: string
}
export default function ApplyButton({jobid}: Props) {
    const supabase = createClient();
    const [isApplied, setIsApplied] = useState<boolean>(true);

    useEffect(() => {
        isAlreadyApplied()
    }, []);

    const isAlreadyApplied = async () => {
        const {
            data: {user},
        } = await supabase.auth.getUser();

        const {data, error} = await supabase
            .from('m2m_job_seeker_job')
            .select()
            .eq("job_id", jobid)
            .eq("job_seeker_id", user?.id)

        console.log(data)

        console.log(error)

        if (error) return;

        if (data != null && data.length > 0) {
            setIsApplied(true);
            return;
        }

        setIsApplied(false)
    }

    const onApply = async () => {

        const result = window.confirm("この企業の求人に応募しますか?");
        if (!result) return

        const {
            data: {user},
        } = await supabase.auth.getUser();
        const {error} = await supabase
            .from('m2m_job_seeker_job')
            .insert({job_seeker_id: user?.id, job_id: jobid, type: "apply"})

        console.log(error)
        await isAlreadyApplied()
    }
    return (<>
        {isApplied ? (<div className="bg-btn-background rounded-md px-4 py-2">応募済み</div>) : (
            <button onClick={onApply} className="bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white">応募
            </button>)}
    </>)
}

その他の追加対応としてブロック後の企業のブロック解除機能を作成します。

components/jobseeker/CompanyLIst.tsx

"use client";
import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import {Database} from "@/types/supabase";
import Link from "next/link";

type Props = {
    searchText: string | null;
};
export default function CompanyList({searchText}: Props) {
    const supabase = createClient();
    const [companyData, setCompanyData] = useState<
        any[]
    >([]);
    const [blockIDs, setBlockIDs] = useState<string[]>([])

    useEffect(() => {
        init();
    }, [searchText]);

    const init = async () => {
        await getBlockIDs()
        let companyData: Database["public"]["Tables"]["mst_company"]["Row"][] = [];
        if (searchText) {
            companyData = await searchCompanyData()
        } else {
            companyData = await getCompanyData()
        }
        const result: any[] = []
        const industryData = await getIndustryData();
        for (let i = 0; i < companyData.length; i++) {
            let industryStr = ""
            for (let j = 0; j < industryData.length; j++) {
                if (companyData[i]["industry_id_1"] === industryData[j]["id"]) {
                    industryStr = industryData![j]["industry"]
                }
            }

            result.push({
                "user_uid": companyData[i]["user_uid"],
                "name": companyData[i]["name"],
                "address1": companyData[i]["address1"],
                "address2": companyData[i]["address2"],
                "employee": companyData[i]["employee"],
                "established_at": companyData[i]["established_at"],
                "industry": industryStr,
            })
        }

        setCompanyData(result)
    }

    const getIndustryData = async () => {
        const {data, error} = await supabase.from("mst_industry").select()
        if (error) {
            console.log(error);
            return [];
        }
        return data as Database["public"]["Tables"]["mst_industry"]["Row"][]
    }

    const getBlockIDs = async () => {
        const {
            data: {user},
        } = await supabase.auth.getUser();
        const {data, error} = await supabase.from("m2m_block_job_seeker_company").select().eq("job_seeker_id", user?.id)
        if (error) {
            console.log(error);
            return;
        }

        const tmp_data: Database["public"]["Tables"]["m2m_block_job_seeker_company"]["Row"][] =
            data as Database["public"]["Tables"]["m2m_block_job_seeker_company"]["Row"][];

        const ids = []
        for (let index = 0; index < tmp_data.length; index++) {
            ids.push(tmp_data[index]["company_id"])
        }

        setBlockIDs(ids);
    }

    const getCompanyData = async () => {
        const {data, error} = await supabase.from("mst_company").select();
        if (error) {
            console.log(error);
            return [];
        }

        console.log(data);

        return data as Database["public"]["Tables"]["mst_company"]["Row"][]
    };

    const searchCompanyData = async () => {
        const searchTexts = searchText!.split(/[\s\u3000]+/);
        let result = `'${searchTexts[0]}'`;
        if (searchTexts.length > 1) {
            // like(あいまい検索)もできるが一旦全文検索で作成
            for (let index = 1; index < searchTexts.length; index++) {
                const element = searchTexts[index];
                result += ` | `;
                result += `'${element}'`;
            }
        }

        const nameData: Database["public"]["Tables"]["mst_company"]["Row"][] | undefined = await nameSearch(result)
        const addressData: Database["public"]["Tables"]["mst_company"]["Row"][] | undefined = await addressSearch(result)
        const jobData: Database["public"]["Tables"]["mst_company"]["Row"][] | undefined = await jobSearch(result)

        return (nameData ? nameData : []).concat(addressData ? addressData : []).concat(jobData ? jobData : [])
    }

    const nameSearch = async (result: string) => {
        const {data, error} = await supabase.from("mst_company").select()
            .textSearch("name", result);

        if (error) {
            console.log(error);
            return;
        }

        return data as Database["public"]["Tables"]["mst_company"]["Row"][]
    }

    const addressSearch = async (result: string) => {
        // address1とaddress2をまとめて返すaddress関数をデータベースに作成した。
        const {data, error} = await supabase.from("mst_company").select()
            .textSearch("address", result);

        if (error) {
            console.log(error);
            return;
        }

        return data as Database["public"]["Tables"]["mst_company"]["Row"][];
    }

    const jobSearch = async (result: string) => {
        const {data, error} = await supabase.from("trn_job").select()
            .textSearch("name", result)

        if (error) {
            console.log(error);
            return [];
        }
        if (data?.length > 0) {
            let tmpData: Database["public"]["Tables"]["trn_job"]["Row"][] = data
            return await getCompanyDataFromJob(tmpData)
        }
    }

    const getCompanyDataFromJob = async (jobs: Database["public"]["Tables"]["trn_job"]["Row"][]) => {
        let resultData: Database["public"]["Tables"]["mst_company"]["Row"][] = []
        for (let index = 0; index < jobs.length; index++) {
            const {data, error} = await supabase.from("mst_company").select()
                .eq("user_uid", jobs[index]["company_uid"]);

            if (error) {
                console.log(error);
            }
            if (data) {
                resultData = resultData.concat(data as Database["public"]["Tables"]["mst_company"]["Row"][])
            }
        }

        return resultData
    }

    const blockCompany = async (id: string) => {
        const {
            data: {user},
        } = await supabase.auth.getUser();
        const {error} = await supabase
            .from('m2m_block_job_seeker_company')
            .insert({job_seeker_id: user?.id, company_id: id})

        await getBlockIDs()
    }

    const unBlockCompany = async (id: string) => {
        const result = window.confirm("この企業のブロックを解除しますか?");
        if (!result) return

        const {
            data: {user},
        } = await supabase.auth.getUser();
        const { error } = await supabase
            .from('m2m_block_job_seeker_company')
            .delete()
            .eq('company_id', id)
            .eq('job_seeker_id', user?.id)

        await init()
    }

    return (
        <div className="flex-1 flex flex-col w-full py-8 max-w-xl justify-center gap-2 animate-in">
            <h1 className="font-bold text-2xl mb-4">企業一覧</h1>
            <ul>
                {companyData.map((item) => (
                    <li key={item.user_uid} className="mb-5">
                        <div
                            className="bg-white rounded-md block w-full hover:bg-gray-200 transition-all p-4 pt-10 border-b border-gray-300 relative">
                            {blockIDs.includes(item.user_uid) ? (
                                <button
                                    className="rounded-tl-md absolute top-0 left-0 bg-red-400 text-white p-1 text-sm"
                                onClick={() => unBlockCompany(item.user_uid)}>ブロック済み(企業があなたを検索できなくなります。)</button>) : (
                                <button
                                    className="rounded-tl-md absolute top-0 left-0 bg-gray-400 text-white p-1 text-sm"
                                    onClick={() => blockCompany(item.user_uid)}>この企業をブロックする</button>
                            )}
                            <Link className="w-full block" href={"/companydetail?companyid=" + item.user_uid}>
                                <h2 className="text-lg text-blue-700 inline-block border-b border-blue-700 mb-4">{item.name}</h2>
                                <div className="flex content-center mb-2">
                                 <span className="material-symbols-outlined">
                                        location_on
                                 </span>
                                    <p className="ml-1">
                                        {item.address1}<br/>
                                        {item.address2}
                                    </p>

                                </div>
                                <dl>
                                    <div className="flex content-center mb-2">
                                        <dt className="font-bold min-w-20">業界</dt>
                                        <span>:</span>
                                        <dd>{item.industry}</dd>
                                    </div>
                                    <div className="flex content-center mb-2">
                                        <dt className="font-bold min-w-20">従業員数</dt>
                                        <span>:</span>
                                        <dd>{item.employee}</dd>
                                    </div>
                                    <div className="flex content-center mb-2">
                                        <dt className="font-bold min-w-20">設立</dt>
                                        <span>:</span>
                                        <dd>{item.established_at}</dd>
                                    </div>
                                </dl>
                            </Link>
                        </div>
                    </li>
                ))}
            </ul>
        </div>
    );
}

実装の確認

企業側でログインし、求職者一覧→求職者詳細にアクセスします。
下までスクロールするとスカウトボタンが表示されます。

スカウトボタンを押すと、スカウト対象の求人一覧が表示されます。


求人を選択すると確認画面が表示され、OKを押すと実際にスカウトが送られ、Supabase側で登録されます。

求職者側でログインし、求人一覧→求人詳細にアクセスします。
下までスクロールすると応募ボタンが表示されます。

応募ボタンを押すと確認画面が表示されOKを押すと、応募済みになります。

Supabase側でも行が追加されていることが確認できます。

その他参考資料など

弊社では『マッチングワン』という『低コスト・短期にマッチングサービスを構築できる』サービスを展開しており、今回ご紹介するコードは、その『マッチングワン』でも使われているコードとなります。
本記事で紹介したようなプロダクトを開発されたい場合は、是非お問い合わせください。

またTodoONada株式会社では、この記事で紹介した以外にも、Supabase・Next.jsを使ったアプリの作成方法についてご紹介しています。
下記記事にて一覧を見ることができますので、ぜひこちらもご覧ください!https://note.com/embed/notes/n522396165049

お問合せ&各種リンク

presented by

  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次