Next.js とSupabaseで求人マッチングアプリを作る⑩~検索機能~

さて、一覧・詳細画面が一通り完成した訳ですが、残りマッチングアプリに必要な機能とはなんでしょうか。

それは『検索機能』です。
たとえ沢山の求人や求職者が登録されていたとしても、ユーザーが見たいのは『条件に合う』リストだけです。
なので、今回は企業側・求職者側それぞれの検索機能を実装します!

企業側 – 求職者の検索機能

求職者 – デフォルトの検索欄(企業 or 案件)と、求人の検索ができる。

目次

Supabaseの設定

今回求職者の検索をするに当たりスキル、資格情報を検索できるようにしたいため、
今まで作成していなかったスキル、資格情報と求職者欄、及び結び付けるテーブルをそれぞれ作成します。

m2m_job_seeker_skill

まず、求職者 x スキルのテーブルですが、
`m2m_job_seeker_skill`という名前で添付のような列構成で作成してください。


`experience_year`以外の列はnull許容しません。
また、`user_uid`は`mst_job_seeker`テーブルの`user_uid`を参照し、
`skill_id`は`mst_skill`テーブルの`id`を参照します。

m2m_job_seeker_qualification

次に求職者 x 資格のテーブルを作成します。
名前は`m2m_job_seeker_qualification`で、
列構成は添付のような形です。


`experience_year`以外の列はnull許容しません。
また、`user_uid`は`mst_job_seeker`テーブルの`user_uid`を参照し、
`qualification_id`は`mst_qualification`テーブルの`id`を参照します。

ポリシー設定

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

address1とaddress2をまとめて扱うデータベース関数作成

`SQL Editor`で下記の内容を実行し、mst_companyの`address1`と`address2`をまとめて検索できるような関数を作成します。

create function address(mst_company) returns text as $$
  select $1.address1 || ' ' || $1.address2;
$$ language sql immutable;

TypeScriptの型生成

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

npx supabase login

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

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

Next.jsの実装

求職者側のテキスト検索

まずは左上のエリアで検索し、検索が終わると企業一覧が表示される機能を作成します。

app/companylist/page.tsx

URLパラメータに検索した値を持って、遷移してきたパターンに対応するための変更を行っています。

import Header from "@/components/Header";
import Footer from "@/components/Footer";
import {createClient} from "@/utils/supabase/server";
import {redirect} from "next/navigation";
import {getUserTypeFromEnv, userType} from "@/utils/usertype";
import CompanyList from "@/components/jobseeker/CompanyLIst";

export default async function CompanyListPage({searchParams}: {
    searchParams: { searchText: string };
}) {

    const supabase = createClient();

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

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

    const usertype = getUserTypeFromEnv();

    if (usertype !== userType.job_seeker) {
        return redirect("/");
    }

    return (
        <div className="flex-1 w-full flex flex-col items-center">
            <Header/>
            <CompanyList searchText={searchParams.searchText}></CompanyList>
            <Footer/>
        </div>
    );
}

app/joblist/page.tsx

求人一覧ページ事態の変更はないですが、個コンポーネントにpropsが追加されたのでその更新をしています。

import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { getUserTypeFromEnv, userType } from "@/utils/usertype";
import JobList from "@/components/jobseeker/JobList";

export default async function JobListPage() {
  const supabase = createClient();

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

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

  const usertype = getUserTypeFromEnv();

  if (usertype !== userType.job_seeker) {
    return redirect("/");
  }

  return (
    <div className="flex-1 w-full flex flex-col items-center">
      <Header />
      <JobList companyid={null}></JobList>
      <Footer />
    </div>
  );
}

components/Header.tsx

ヘッダーに検索欄を追加しています。

import { createClient } from "@/utils/supabase/server";
import Link from "next/link";
import { redirect } from "next/navigation";
import { userType, getUserTypeFromEnv } from "@/utils/usertype";
import HeaderSearchForm from "@/components/jobseeker/HeaderSearchForm";

export default async function Header() {
  const supabase = createClient();

  const usertype = getUserTypeFromEnv();

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

  const signOut = async () => {
    "use server";

    const supabase = createClient();
    await supabase.auth.signOut();
    return redirect("/login");
  };

  return (
    <nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
      <div className="w-full max-w-4xl flex justify-center items-center p-3 text-sm">
        {user && usertype == userType.company ? (
          <div className="flex items-center gap-4">
            <Link href="/myjoblist">自社の求人一覧</Link>
            <Link href="/jobseekerlist">求職者一覧</Link>
            <Link href="/userpage">ユーザページ</Link>
            <form action={signOut}>
              <button className="py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover">
                ログアウト
              </button>
            </form>
          </div>
        ) : user && usertype == userType.job_seeker ? (
          <div className="flex items-center gap-4">
            <HeaderSearchForm></HeaderSearchForm>
            <Link href="/joblist">求人一覧</Link>
            <Link href="/companylist">企業一覧</Link>
            <Link href="/userpage">ユーザページ</Link>
            <form action={signOut}>
              <button className="py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover">
                ログアウト
              </button>
            </form>
          </div>
        ) : (
          <div className="flex items-center gap-4">
            <Link
              href="/login"
              className="py-2 px-3 flex rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
            >
              ログイン
            </Link>
            <Link
              href="/signup"
              className="py-2 px-3 flex rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
            >
              会員登録
            </Link>
          </div>
        )}
      </div>
    </nav>
  );
}

components/jobseeker/CompanyDetail.tsx

企業詳細情報のコンポーネントへ、企業の求人も表示するよう変更しています。

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

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

    const [companyname, setCompanyname] = useState("");
    const [zipcode, setZipcode] = useState("");
    const [address1, setAddress1] = useState("");
    const [address2, setAddress2] = useState("");
    const [phone, setPhone] = useState<string | null>(null);
    const [email, setEmail] = useState("");
    const [companyurl, setCompanyurl] = useState<string | null>(null);
    const [capital, setCapital] = useState<number | null>(null);
    const [employee, setEmployee] = useState<number | null>(null);
    const [annual_turnover, setAnnual_turnover] = useState<number | null>(null);
    const [established_at, setEstablishd_at] = useState<string | null>(null);
    const [industry_name_1, setIndustry_name_1] = useState<string | null>(null);
    const [industry_name_2, setIndustry_name_2] = useState<string | null>(null);
    const [industry_name_3, setIndustry_name_3] = useState<string | null>(null);
    const [company_image_url_1, setCompany_image_url_1] = useState<string | null>(
        null
    );
    const [company_image_url_2, setCompany_image_url_2] = useState<string | null>(
        null
    );
    const [company_image_url_3, setCompany_image_url_3] = useState<string | null>(
        null
    );
    const [company_image_url_4, setCompany_image_url_4] = useState<string | null>(
        null
    );
    const [company_image_url_5, setCompany_image_url_5] = useState<string | null>(
        null
    );

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

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

        if (data != null && data?.length != 0) {
            const company_data: Database["public"]["Tables"]["mst_company"]["Row"] =
                data[0];
            setCompanyname(company_data.name);
            setZipcode(company_data.zipcode);
            setAddress1(company_data.address1);
            setAddress2(company_data.address2);
            if (company_data.phone != null) {
                setPhone(company_data.phone);
            }
            setEmail(company_data.email);
            if (company_data.url != null) {
                setCompanyurl(company_data.url);
            }
            if (company_data.capital != null) {
                setCapital(company_data.capital);
            }
            if (company_data.employee != null) {
                setEmployee(company_data.employee);
            }
            if (company_data.annual_turnover != null) {
                setAnnual_turnover(company_data.annual_turnover);
            }
            if (company_data.established_at != null) {
                setEstablishd_at(company_data.established_at);
            }
            if (company_data.industry_id_1 != null) {
                const name = await getIndustyName(company_data.industry_id_1);
                setIndustry_name_1(name);
            }
            if (company_data.industry_id_2 != null) {
                const name = await getIndustyName(company_data.industry_id_2);
                setIndustry_name_2(name);
            }
            if (company_data.industry_id_3 != null) {
                const name = await getIndustyName(company_data.industry_id_3);
                setIndustry_name_3(name);
            }
            if (company_data.company_image_url_1 != null) {
                setCompany_image_url_1(company_data.company_image_url_1);
            }
            if (company_data.company_image_url_2 != null) {
                setCompany_image_url_2(company_data.company_image_url_2);
            }
            if (company_data.company_image_url_3 != null) {
                setCompany_image_url_3(company_data.company_image_url_3);
            }
            if (company_data.company_image_url_4 != null) {
                setCompany_image_url_4(company_data.company_image_url_4);
            }
            if (company_data.company_image_url_5 != null) {
                setCompany_image_url_5(company_data.company_image_url_5);
            }
        }
    };

    const getIndustyName = 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 sm:max-w-md justify-center gap-2 animate-in">
                <h1>企業詳細</h1>
                <ul>
                    <DetailList title={"会社名"} value={companyname}/>
                    <DetailList title={"郵便番号"} value={zipcode}/>
                    <DetailList title={"住所1"} value={address1}/>
                    <DetailList title={"住所2"} value={address2}/>
                    <DetailList title={"電話番号"} value={phone}/>
                    <DetailList title={"メール"} value={email}/>
                    <DetailList title={"企業URL"} value={companyurl}/>
                    <DetailList title={"資本金"} value={capital}/>
                    <DetailList title={"従業員数"} value={employee}/>
                    <DetailList title={"年商"} value={annual_turnover}/>
                    <DetailList title={"設立"} value={established_at}/>
                    <DetailList title={"業界1"} value={industry_name_1}/>
                    <DetailList title={"業界2"} value={industry_name_2}/>
                    <DetailList title={"業界3"} value={industry_name_3}/>
                    <DetailList title={"会社の画像1のURL"} value={company_image_url_1}/>
                    <DetailList title={"会社の画像2のURL"} value={company_image_url_2}/>
                    <DetailList title={"会社の画像3のURL"} value={company_image_url_3}/>
                    <DetailList title={"会社の画像4のURL"} value={company_image_url_4}/>
                    <DetailList title={"会社の画像5のURL"} value={company_image_url_5}/>

                </ul>
            </div>
            <JobList companyid={companyid}></JobList>
        </>
    );
}

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<
        Database["public"]["Tables"]["mst_company"]["Row"][]
    >([]);

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

    const init = async () => {
        if (searchText) {
            await searchCompanyData()
        } else {
            await getCompanyData()
        }
    }

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

        console.log(data);

        const tmp_data: Database["public"]["Tables"]["mst_company"]["Row"][] =
            data as Database["public"]["Tables"]["mst_company"]["Row"][];
        setCompanyData(tmp_data);
    };

    const searchCompanyData = async () => {
        const searchTexts = searchText!.split(/[\s\u3000]+/);
        let result = `'${searchTexts[0]}'`;
        if (searchTexts.length > 1) {
            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)

        const newData = (nameData? nameData : []).concat(addressData? addressData : []).concat(jobData? jobData : [])
        setCompanyData(newData)
    }

    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
    }

    return (
        <div className="flex-1 flex flex-col w-full py-8 sm:max-w-md justify-center gap-2 animate-in">
            <h1>企業一覧</h1>
            <ul>
                {companyData.map((item) => (
                    <li key={item.user_uid}>
                        <Link
                            href={"/companydetail?companyid=" + item.user_uid}
                            className="text-blue-700 hover:text-blue-600 border-b"
                        >
                            {item.name}
                        </Link>
                    </li>
                ))}
            </ul>
        </div>
    );
}

searchTextの値が存在していたら、下記の`SearchCompanyData`で検索処理を行い、企業一覧をフィルタリングします。

const searchCompanyData = async () => {
        const searchTexts = searchText!.split(/[\s\u3000]+/);
        let result = `'${searchTexts[0]}'`;
        if (searchTexts.length > 1) {
            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)

        const newData = (nameData? nameData : []).concat(addressData? addressData : []).concat(jobData? jobData : [])
        setCompanyData(newData)
    }

components/jobseeker/HeaderSearchForm.tsx

ヘッダー上の検索欄です。

"use client"
import {useState} from "react";
import Link from "next/link";

export default function HeaderSearchForm() {
    const [searchText, setSearchText] = useState("")

    const handleChangeText = (text: string) => {
        setSearchText(text)
    };
    return (<div className="flex w-96">
            <input
                type="text"
                id="search"
                className="block w-full p-2 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 "
                placeholder="検索欄"
                onChange={(e) => handleChangeText(e.target.value)}
                required
            />
            <Link href={"/companylist?searchText=" + searchText} className=" text-center text-white w-16 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-2 py-2 ">検索</Link>
        </div>
    )
}

求職者側のテキスト検索実装確認

上で実装できたため、まずは求職者側のテキスト検索の確認をしていきます。
求職者側でログインすると、上にヘッダーに検索欄が追加されていることがわかります。
ここに作成した企業の名前を入れてみます。

検索ボタンを押すと企業一覧に遷移し、検索結果が表示されます。

URL欄もsearchTextが追加され、検索に利用した情報がわかります。

また、企業名だけでなく住所、求人名でも検索可能です。試してみてください。

求職者の求人検索実装

次に求人の検索を求人一覧画面に実装します。
今回は年収、勤務地、職種、業界、雇用区分、休日の6つの情報で検索できるよう作成します。

components/jobseeker/JobList.tsx

求人一覧のコンポーネントです。

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

type Props = {
  companyid: string | null;
};
export default function JobList({ companyid }: Props) {
  const supabase = createClient();
  const [jobData, setJobData] = useState<
    Database["public"]["Tables"]["trn_job"]["Row"][]
  >([]);

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

  const getJobData = async () => {
    if (companyid != null) {
      const { data, error } = await supabase.from("trn_job").select().eq("company_uid", companyid);
      if (error) {
        console.log(error);
        return;
      }
      const tmp_data: Database["public"]["Tables"]["trn_job"]["Row"][] =
          data as Database["public"]["Tables"]["trn_job"]["Row"][];
      setJobData(tmp_data);
    } else {
      const { data, error } = await supabase.from("trn_job").select();
      if (error) {
        console.log(error);
        return;
      }
      const tmp_data: Database["public"]["Tables"]["trn_job"]["Row"][] =
          data as Database["public"]["Tables"]["trn_job"]["Row"][];
      setJobData(tmp_data);
    }



  };
  return (
    <div className="flex-1 flex flex-col w-full py-8 sm:max-w-md justify-center gap-2 animate-in">
      <h1>募集求人一覧</h1>
      <JobSearch tableData={setJobData}></JobSearch>
      <ul>
        {jobData.map((item) => (
          <li
            key={item.id}
            className="bg-gray-200 rounded-md p-5 mt-4 hover:bg-gray-300"
          >
            <Link href={"/jobdetail?jobid=" + item.id} className="block w-full">
              <h2>{item.name}</h2>
              <div className="flex">
                <div className="w-1/2">
                  <h3>雇用形態</h3>
                  <p>
                    {item.employment_class == 1
                      ? "正社員"
                      : item.employment_class == 2
                      ? "契約社員"
                      : "アルバイト"}
                  </p>
                </div>
                <div className="w-1/2">
                  <h3>勤務地</h3>
                  <p>{item.work_location}</p>
                </div>
              </div>
              <div className="flex">
                <div className="w-1/2">
                  <h3>応募資格</h3>
                  <p>{item.qualification}</p>
                </div>
                <div className="w-1/2">
                  <h3>給与</h3>
                  <p>{item.annual_income}</p>
                </div>
              </div>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

components/jobseeker/JobSearch.tsx

求人検索用のコンポーネントです。
結構複雑な仕組みで動かしているので後程説明します。

"use client"
import {createClient} from "@/utils/supabase/client";
import {Dispatch, SetStateAction, useEffect, useState} from "react";
import {Database} from "@/types/supabase";
import DefaultInput from "@/components/common/DefaultInput";
import {InputType} from "@/utils/inputType";
import DefaultSelect from "@/components/common/DefaultSelect";
import {DatabaseType} from "@/utils/DatabaseType";

export default function JobSearch(props: {
    tableData: Dispatch<
        SetStateAction<Database["public"]["Tables"]["trn_job"]["Row"][]>
    >;
}) {
    const supabase = createClient()
    const [work_locationData, setWork_locationData] = useState<
        Database["public"]["Tables"]["mst_work_location"]["Row"][]
    >([]);
    const [day_offData, setDay_offData] = useState<
        Database["public"]["Tables"]["mst_day_off"]["Row"][]
    >([]);
    const [occupation, setOccupation] = useState<
        Database["public"]["Tables"]["mst_occupation"]["Row"][]
    >([]);
    const [industry, setIndustry] = useState<
        Database["public"]["Tables"]["mst_industry"]["Row"][]
    >([]);

    const [annualIncome, setAnnualIncome] = useState<number>(0)
    const [work_location, setWorkLocation] = useState<string>("")
    const [occupation_id, setOccupation_id] = useState<string>("");
    const [industry_id, setIndustry_id] = useState<string>("");
    const [employment_class, setEmployment_class] = useState<number>();
    const [day_off, setDay_off] = useState<string>("");

    useEffect(() => {
        getDayoffData();
        getWorklocationData();
        getOccupation();
        getIndustry();
    }, []);

    const getWorklocationData = async () => {
        const {data, error} = await supabase.from("mst_work_location").select();
        if (error) {
            console.log(error);
            return;
        }

        const fixed_data: Database["public"]["Tables"]["mst_work_location"]["Row"][] =
            data;
        setWork_locationData(fixed_data);
    };

    const getDayoffData = async () => {
        const {data, error} = await supabase.from("mst_day_off").select();
        if (error) {
            console.log(error);
            return;
        }

        const fixed_data: Database["public"]["Tables"]["mst_day_off"]["Row"][] =
            data;
        setDay_offData(fixed_data);
    };

    const getOccupation = async () => {
        const {data, error} = await supabase.from("mst_occupation").select();
        if (error) {
            console.log(error);
            return;
        }

        const fixed_data: Database["public"]["Tables"]["mst_occupation"]["Row"][] =
            data;
        setOccupation(fixed_data);
    };

    const getIndustry = async () => {
        const {data, error} = await supabase.from("mst_industry").select();
        if (error) {
            console.log(error);
            return;
        }

        const fixed_data: Database["public"]["Tables"]["mst_industry"]["Row"][] =
            data;
        setIndustry(fixed_data);
    };

    const onSubmit = async (event: any) => {
        event.preventDefault();

        // 全パターン確認
        const annualIncomeResult = await annualIncomeSearch()
        let workLocationResult: Database["public"]["Tables"]["trn_job"]["Row"][] | null = null
        let occupationResult: Database["public"]["Tables"]["trn_job"]["Row"][] | null = null
        let industryResult: Database["public"]["Tables"]["trn_job"]["Row"][] | null = null
        let dayoffResult: Database["public"]["Tables"]["trn_job"]["Row"][] | null = null
        let employmentClassResult: Database["public"]["Tables"]["trn_job"]["Row"][] | null = null

        if (work_location !== "") {
            workLocationResult = await workLocationSearch()
        }
        if (occupation_id !== "") {
            occupationResult = await occupationSearch()
        }
        if (industry_id !== "") {
            industryResult = await industrySearch()
        }
        if (day_off !== "") {
            dayoffResult = await dayOffSearch()
        }
        if (employment_class != undefined) {
            employmentClassResult = await employmentClassSearch()
        }

        const data: Database["public"]["Tables"]["trn_job"]["Row"][] = (annualIncomeResult ? annualIncomeResult : [])
            .concat(workLocationResult ? workLocationResult : annualIncomeResult)
            .concat(occupationResult ? occupationResult : annualIncomeResult)
            .concat(industryResult ? industryResult : annualIncomeResult)
            .concat(dayoffResult ? dayoffResult : annualIncomeResult)
            .concat(employmentClassResult ? employmentClassResult : annualIncomeResult)

        const isSelectedId: Map<string, number> = new Map()

        // 重複のみ取り出し
        let fixed_data = []
        for (let index = 0; index < data.length; index++) {
            const currentCount = isSelectedId.get(data[index]["id"])
            if (currentCount != undefined) {
                isSelectedId.set(data[index]["id"], isSelectedId.get(data[index]["id"])! + 1)
            } else {
                isSelectedId.set(data[index]["id"], 1)
            }

            if (isSelectedId.get(data[index]["id"]) == 6) {
                fixed_data.push(data[index])
            }
        }

        console.log(fixed_data)

        props.tableData(fixed_data);
    };

    const annualIncomeSearch = async () => {
        const {data, error} = await supabase.from("trn_job").select()
            .gte("annual_income", annualIncome)

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

        return data
    }

    const workLocationSearch = async () => {
        const {data, error} = await supabase.from("trn_job").select()
            .eq("work_location", work_location)

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

        return data
    }

    const occupationSearch = async () => {
        const {data, error} = await supabase.from("trn_job").select()
            .eq("occupation_id", occupation_id)

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

        return data
    }

    const industrySearch = async () => {
        const {data, error} = await supabase.from("trn_job").select()
            .eq("industry_id", industry_id)

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

        return data
    }
    const dayOffSearch = async () => {
        const {data, error} = await supabase.from("trn_job").select()
            .eq("day_off", day_off)

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

        return data
    }

    const employmentClassSearch = async () => {
        const {data, error} = await supabase.from("trn_job").select()
            .eq("employment_class", employment_class)

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

        return data
    }

    return (
        <form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground"
              onSubmit={onSubmit}>
            <h2>求人検索</h2>
            <DefaultInput name={"annual_income"} value={annualIncome} setter={setAnnualIncome} isRequired={false}
                          labelText={"想定年収(○万円以上)"} placeholderText={""} inputType={InputType.number}/>
            <DefaultSelect name={"work_location"} value={work_location} setter={setWorkLocation} isRequired={false}
                           labelText={"勤務地"} selectData={work_locationData} databaseType={DatabaseType.mst_work_location}/>

            <DefaultSelect name={"occupation_id"} value={occupation_id} setter={setOccupation_id} isRequired={false}
                           labelText={"職種"} selectData={occupation} databaseType={DatabaseType.mst_occupation}/>

            <DefaultSelect name={"industry_id"} value={industry_id} setter={setIndustry_id} isRequired={false}
                           labelText={"業界"} selectData={industry} databaseType={DatabaseType.mst_industry}/>

            <DefaultSelect name={"employment_class"} value={employment_class ? employment_class : ""} setter={setEmployment_class} isRequired={false}
                           labelText={"雇用区分"} selectData={[]} databaseType={DatabaseType.mst_employment_class}/>

            <DefaultSelect name={"day_off"} value={day_off} setter={setDay_off} isRequired={false}
                           labelText={"休日"} selectData={day_offData} databaseType={DatabaseType.mst_day_off}/>

            <button
                type="submit"
                className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 "
            >
                検索
            </button>
        </form>
    );
}

今回全要素をand検索したいのですが、検索対象が各所に分かれているため、絶対に行う年収の検索をベースにしてすべての検索を行い、6回重複する(=and検索すべてを通過する)もののみを表示するようにしています。

const data: Database["public"]["Tables"]["trn_job"]["Row"][] = (annualIncomeResult ? annualIncomeResult : [])
            .concat(workLocationResult ? workLocationResult : annualIncomeResult)
            .concat(occupationResult ? occupationResult : annualIncomeResult)
            .concat(industryResult ? industryResult : annualIncomeResult)
            .concat(dayoffResult ? dayoffResult : annualIncomeResult)
            .concat(employmentClassResult ? employmentClassResult : annualIncomeResult)

        const isSelectedId: Map<string, number> = new Map()

        // 重複のみ取り出し
        let fixed_data = []
        for (let index = 0; index < data.length; index++) {
            const currentCount = isSelectedId.get(data[index]["id"])
            if (currentCount != undefined) {
                isSelectedId.set(data[index]["id"], isSelectedId.get(data[index]["id"])!  + 1)
            } else {
                isSelectedId.set(data[index]["id"], 1)
            }

            if (isSelectedId.get(data[index]["id"]) == 6) {
                fixed_data.push(data[index])
            }
        }

        console.log(fixed_data)

        props.tableData(fixed_data);

求職者側の求人検索確認

求職者側でログインし、求人一覧を開きます。


すると、求人検索欄が一番上に表示されます。
こちらに要素を入れて検索すると、求人一覧の表示されている内容が変化します。

企業側の求職者検索

最後に求職者の検索機能の作成をします。
求職者はスキル、資格、希望年収、現在年収、年齢、性別の6つの要素で検索を行います。

components/company/JobSeekerList.tsx

求人一覧画面に検索欄を追加しています。

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

export default function JobSeekerList() {
  const supabase = createClient();
  const [jobSeekerData, setJobSeekerData] = useState<
    Database["public"]["Tables"]["mst_job_seeker"]["Row"][]
  >([]);

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

  const getJobSeekerData = async () => {
    const { data, error } = await supabase.from("mst_job_seeker").select();
    if (error) {
      console.log(error);
      return;
    }

    console.log(data);

    const tmp_data: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] =
      data as Database["public"]["Tables"]["mst_job_seeker"]["Row"][];
    setJobSeekerData(tmp_data);
  };

  return (
    <div className="flex-1 flex flex-col w-full py-8 sm:max-w-md justify-center gap-2 animate-in">
      <h1>求職者一覧</h1>
      <JobSeekerSearch tableData={setJobSeekerData}></JobSeekerSearch>
      <ul>
        {jobSeekerData.map((item) => (
          <li
            key={item.user_uid}
            className="bg-gray-200 rounded-md p-5 mt-4 hover:bg-gray-300"
          >
            <Link
              href={"/jobseekerdetail?jobseekerid=" + item.user_uid}
              className="block w-full"
            >
              <h2>{item.last_name + " " + item.first_name}</h2>
              <p>ユーザID:{item.user_uid}</p>
              <div className="flex">
                <div className="w-1/2">
                  <h3>性別</h3>
                  <p>
                    {item.gender == 0
                      ? "女性"
                      : item.gender == 1
                      ? "男性"
                      : "その他"}
                  </p>
                </div>
                <div className="w-1/2">
                  <h3>生年月日</h3>
                  <p>{item.birthday}</p>
                </div>
              </div>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

components/company/JobSeekerSearch.tsx

求職者検索のコンポーネントです。
仕組みはほとんど求人検索と同じような形で、スキル、資格のみm2mのテーブルを一度経由して取得するような仕組みになっています。

"use client"
import {createClient} from "@/utils/supabase/client";
import {Dispatch, SetStateAction, useEffect, useState} from "react";
import {Database} from "@/types/supabase";
import DefaultSelect from "@/components/common/DefaultSelect";
import {DatabaseType} from "@/utils/DatabaseType";
import DefaultInput from "@/components/common/DefaultInput";
import {InputType} from "@/utils/inputType";

export default function JobSeekerSearch(props: {
    tableData: Dispatch<
        SetStateAction<Database["public"]["Tables"]["mst_job_seeker"]["Row"][]>
    >;
}) {
    const supabase = createClient()
    const [qualificationData, setQualificationData] = useState<
        Database["public"]["Tables"]["mst_qualification"]["Row"][]
    >([]);
    const [skillData, setSkillData] = useState<
        Database["public"]["Tables"]["mst_skill"]["Row"][]
    >([]);

    // スキル、資格、希望年収、現在年収、年齢、性別
    const [skill, setSkill] = useState<string>("")
    const [qualification, setQualification] = useState<string>("")
    const [desired_annual_income, setDesiredAnnualIncome] = useState<number | null>(null)
    const [current_annual_income, setCurrentAnnualIncome] = useState<number | null>(null)
    const [old, setOld] = useState<number>(0)
    const [gender, setGender] = useState<string>("")

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

    const getSkillData = async () => {
        const {data, error} = await supabase.from("mst_skill").select();
        if (error) {
            console.log(error);
            return;
        }

        const fixed_data: Database["public"]["Tables"]["mst_skill"]["Row"][] =
            data;
        setSkillData(fixed_data);
    };

    const getQualificationData = async () => {
        const {data, error} = await supabase.from("mst_qualification").select();
        if (error) {
            console.log(error);
            return;
        }

        const fixed_data: Database["public"]["Tables"]["mst_qualification"]["Row"][] =
            data;
        setQualificationData(fixed_data);
    };

    const onSubmit = async (event: any) => {
        event.preventDefault();
        const oldResult: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] = await oldSearch()
        let desiredAnnualIncomeResult: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] | null = null
        let currentAnnualIncomeResult: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] | null = null
        let genderResult: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] | null = null
        let skillResult: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] | null = null
        let qualificationResult: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] | null = null


        if (desired_annual_income != null && !isNaN(desired_annual_income)) {
            desiredAnnualIncomeResult = await desiredAnnualIncomeSearch()
        }

        if (current_annual_income != null && !isNaN(current_annual_income)) {
            currentAnnualIncomeResult = await currentAnnualIncomeSearch()
        }

        if (gender !== "") {
            genderResult = await genderSearch()
        }

        if (skill !== "") {
            skillResult = await skillSearch()
        }

        if (qualification !== "") {
            qualificationResult = await qualificationSearch()
        }

        const data: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] = (oldResult ? oldResult : [])
            .concat(desiredAnnualIncomeResult ? desiredAnnualIncomeResult : oldResult)
            .concat(currentAnnualIncomeResult ? currentAnnualIncomeResult : oldResult)
            .concat(genderResult ? genderResult : oldResult)
            .concat(skillResult ? skillResult : oldResult)
            .concat(qualificationResult ? qualificationResult : oldResult)

        const isSelectedId: Map<string, number> = new Map()

        // 重複のみ取り出し
        let fixed_data = []
        for (let index = 0; index < data.length; index++) {
            const currentCount = isSelectedId.get(data[index]["user_uid"])
            if (currentCount != undefined) {
                isSelectedId.set(data[index]["user_uid"], isSelectedId.get(data[index]["user_uid"])! + 1)
            } else {
                isSelectedId.set(data[index]["user_uid"], 1)
            }

            if (isSelectedId.get(data[index]["user_uid"]) == 6) {
                fixed_data.push(data[index])
            }
        }

        console.log(fixed_data)

        props.tableData(fixed_data);
    };

    const oldSearch = async () => {
        const birthday = oldToBirthday()
        const {data, error} = await supabase.from("mst_job_seeker").select()
            .lte("birthday", birthday)

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

        return data
    }

    const oldToBirthday = () => {
        const currentYear = new Date().getFullYear();
        return `${currentYear - old}-01-01`;
    }

    const desiredAnnualIncomeSearch = async () => {
        const {data, error} = await supabase.from("mst_job_seeker").select()
            .gte("desired_annual_income", desired_annual_income)

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

        return data
    }

    const currentAnnualIncomeSearch = async () => {
        const {data, error} = await supabase.from("mst_job_seeker").select()
            .gte("current_annual_income", current_annual_income)

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

        return data
    }

    const genderSearch = async () => {
        const {data, error} = await supabase.from("mst_job_seeker").select()
            .eq("gender", gender)

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

        return data
    }

    const skillSearch = async () => {
        const {data, error} = await supabase.from("m2m_job_seeker_skill").select()
            .eq("skill_id", skill)

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

        const tmpData: Database["public"]["Tables"]["m2m_job_seeker_skill"]["Row"][] = data

        return await getJobSeekersFromSkillm2m(tmpData)
    }

    const getJobSeekersFromSkillm2m = async (m2mData: Database["public"]["Tables"]["m2m_job_seeker_skill"]["Row"][]) => {
        let result: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] = []
        for (let index = 0; index < m2mData.length; index++) {
            const user_uid = m2mData[index]["user_uid"]
            const {data, error} = await supabase.from("mst_job_seeker").select()
                .eq("user_uid", user_uid)
            if (error) {
                console.log(error);
                continue
            }
            result = result.concat(data)
        }

        console.log(result)
        return result
    }

    const qualificationSearch = async () => {
        const {data, error} = await supabase.from("m2m_job_seeker_qualification").select()
            .eq("qualification_id", skill)

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

        const tmpData: Database["public"]["Tables"]["m2m_job_seeker_qualification"]["Row"][] = data

        return await getJobSeekersFromQualificationm2m(tmpData)
    }

    const getJobSeekersFromQualificationm2m = async (m2mData: Database["public"]["Tables"]["m2m_job_seeker_qualification"]["Row"][]) => {
        let result: Database["public"]["Tables"]["mst_job_seeker"]["Row"][] = []
        for (let index = 0; index < m2mData.length; index++) {
            const user_uid = m2mData[index]["user_uid"]
            const {data, error} = await supabase.from("mst_job_seeker").select()
                .eq("user_uid", user_uid)
            if (error) {
                console.log(error);
                continue
            }
            result = result.concat(data)
        }

        console.log(result)
        return result
    }

    return (
        <form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground"
              onSubmit={onSubmit}>
            <h2>求職者検索</h2>
            <DefaultSelect name={"skill"} value={skill ? skill : ""} setter={setSkill} isRequired={false}
                           labelText={"スキル"}
                           selectData={skillData} databaseType={DatabaseType.mst_skill}/>
            <DefaultSelect name={"qualification"} value={qualification ? qualification : ""} setter={setQualification}
                           isRequired={false} labelText={"資格"}
                           selectData={qualificationData} databaseType={DatabaseType.mst_qualification}/>
            <DefaultInput name={"desired_annual_income"} value={desired_annual_income ? desired_annual_income : ""}
                          setter={setDesiredAnnualIncome} isRequired={false}
                          labelText={"希望年収(○万円以上)"} placeholderText={""} inputType={InputType.number}/>

            <DefaultInput name={"current_annual_income"} value={current_annual_income ? current_annual_income : ""}
                          setter={setCurrentAnnualIncome} isRequired={false}
                          labelText={"現在年収(○万円以上)"} placeholderText={""} inputType={InputType.number}/>

            <DefaultInput name={"old"} value={old}
                          setter={setOld} isRequired={false}
                          labelText={"年齢(○歳以上)"} placeholderText={""} inputType={InputType.number}/>

            <DefaultSelect name={"gender"} value={gender} setter={setGender}
                           isRequired={false} labelText={"性別"}
                           selectData={[]} databaseType={DatabaseType.mst_gender}/>

            <button
                type="submit"
                className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2 "
            >
                検索
            </button>
        </form>
    );
}

企業側の求職者検索確認

企業側でログインして求職者一覧を開きます。


すると求職者の検索のための入力欄がまず表示されます。
こちらを利用して検索するとその内容にあった求職者のみが表示されます。

その他参考資料など

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

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

お問合せ&各種リンク

presented by

よかったらシェアしてね!
  • URLをコピーしました!
目次