見出し画像

Next.js とSupabaseで求人マッチングアプリを作る⑯~管理者アプリ実装~

今回は、新たに管理者向けのアプリの作成をしていきたいと思います!

必要な要件としては『管理者アカウントのみログインできること』
『これまで作成してきた、企業情報、求人情報、求職者情報、チャットのすべてを閲覧でき、修正できるようにすること』です。

もちろん仕組み部分はすでにできているので、あとはつなぐだけですが、権限や表示方法等に気をつけながら実装していきましょう。

Supabase側の対応

※以前まで利用していたSupabaseのプロジェクトをそのまま利用します。

adminユーザの作成

今までの講座の中でユーザ自体は作成できていると思いますので、
このSupabaseのダッシュボードにあるテーブル一覧から`mst_user_type`にアクセスします。
管理者にしたいユーザのuser_typeを`admin`に変更してください。

ポリシーの修正

いくつかのテーブルにおいて、更新の際にログインユーザ自身のみ更新できる設定にしていたため、管理者も修正できるよう変更します。
対象は`mst_company`、`mst_job_seeker`、`trn_job`の三つで、
それぞれupdateのポリシーを`true`に変更してください。

Next.jsのプロジェクト作成

今回再度別プロジェクトとして位置から作成するため、下記URLを参考に対応しましょう。
https://supabase.com/docs/guides/getting-started/quickstarts/nextjs

まず下記コマンドで、新たなプロジェクトを任意のフォルダに作成します。

npx create-next-app -e with-supabase

その後、`.env.example`ファイルの名前を`.env.local`に変更してください。
また、ファイル内の`NEXT_PUBLIC_SUPABASE_URL`と`NEXT_PUBLIC_SUPABASE_ANON_KEY`をそれぞれSupabaseプロジェクトに合わせて変更してください。

プロジェクト内で`npm run dev`してプロジェクトが立ち上がればOKです。

Supabaseの型生成

Supabaseにログインします。

npx supabase login

その後、下記のコマンドを実行して型を生成します。

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

tailadminの導入

今回管理画面を作成する上でスムーズに開発を行うために、tailadminを利用します。
こちらはtailwindcssを利用した便利なコンポーネントのサンプル集となっており、局所的に必要なコンポーネントのみを抜き出して利用することが可能です。

下記URLからDL・展開して確認してみてください。
https://github.com/TailAdmin/free-nextjs-admin-dashboard

今回は先ほど作成したプロジェクトに段階的にこちらを導入していきます。

tailwind.config.tsの変更

tailwindのスタイルをtailadminに合わせるため、下記のように変更します。

import defaultTheme from "tailwindcss/defaultTheme";

const config = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  darkMode: "class",
  theme: {
    screens: {
      "2xsm": "375px",
      xsm: "425px",
      "3xl": "2000px",
      ...defaultTheme.screens,
    },
    extend: {
      colors: {
        current: "currentColor",
        transparent: "transparent",
        white: "#FFFFFF",
        black: "#1C2434",
        red: "#FB5454",
        "black-2": "#010101",
        body: "#64748B",
        bodydark: "#AEB7C0",
        bodydark1: "#DEE4EE",
        bodydark2: "#8A99AF",
        primary: "#3C50E0",
        secondary: "#80CAEE",
        stroke: "#E2E8F0",
        gray: "#EFF4FB",
        graydark: "#333A48",
        "gray-2": "#F7F9FC",
        "gray-3": "#FAFAFA",
        whiten: "#F1F5F9",
        whiter: "#F5F7FD",
        boxdark: "#24303F",
        "boxdark-2": "#1A222C",
        strokedark: "#2E3A47",
        "form-strokedark": "#3d4d60",
        "form-input": "#1d2a39",
        "meta-1": "#DC3545",
        "meta-2": "#EFF2F7",
        "meta-3": "#10B981",
        "meta-4": "#313D4A",
        "meta-5": "#259AE6",
        "meta-6": "#FFBA00",
        "meta-7": "#FF6766",
        "meta-8": "#F0950C",
        "meta-9": "#E5E7EB",
        "meta-10": "#0FADCF",
        success: "#219653",
        danger: "#D34053",
        warning: "#FFA70B",
      },
      fontSize: {
        "title-xxl": ["44px", "55px"],
        "title-xxl2": ["42px", "58px"],
        "title-xl": ["36px", "45px"],
        "title-xl2": ["33px", "45px"],
        "title-lg": ["28px", "35px"],
        "title-md": ["24px", "30px"],
        "title-md2": ["26px", "30px"],
        "title-sm": ["20px", "26px"],
        "title-sm2": ["22px", "28px"],
        "title-xsm": ["18px", "24px"],
      },
      spacing: {
        4.5: "1.125rem",
        5.5: "1.375rem",
        6.5: "1.625rem",
        7.5: "1.875rem",
        8.5: "2.125rem",
        9.5: "2.375rem",
        10.5: "2.625rem",
        11: "2.75rem",
        11.5: "2.875rem",
        12.5: "3.125rem",
        13: "3.25rem",
        13.5: "3.375rem",
        14: "3.5rem",
        14.5: "3.625rem",
        15: "3.75rem",
        15.5: "3.875rem",
        16: "4rem",
        16.5: "4.125rem",
        17: "4.25rem",
        17.5: "4.375rem",
        18: "4.5rem",
        18.5: "4.625rem",
        19: "4.75rem",
        19.5: "4.875rem",
        21: "5.25rem",
        21.5: "5.375rem",
        22: "5.5rem",
        22.5: "5.625rem",
        24.5: "6.125rem",
        25: "6.25rem",
        25.5: "6.375rem",
        26: "6.5rem",
        27: "6.75rem",
        27.5: "6.875rem",
        29: "7.25rem",
        29.5: "7.375rem",
        30: "7.5rem",
        31: "7.75rem",
        32.5: "8.125rem",
        33: "8.25rem",
        34: "8.5rem",
        34.5: "8.625rem",
        35: "8.75rem",
        36.5: "9.125rem",
        37.5: "9.375rem",
        39: "9.75rem",
        39.5: "9.875rem",
        40: "10rem",
        42.5: "10.625rem",
        44: "11rem",
        45: "11.25rem",
        46: "11.5rem",
        47.5: "11.875rem",
        49: "12.25rem",
        50: "12.5rem",
        52: "13rem",
        52.5: "13.125rem",
        54: "13.5rem",
        54.5: "13.625rem",
        55: "13.75rem",
        55.5: "13.875rem",
        59: "14.75rem",
        60: "15rem",
        62.5: "15.625rem",
        65: "16.25rem",
        67: "16.75rem",
        67.5: "16.875rem",
        70: "17.5rem",
        72.5: "18.125rem",
        73: "18.25rem",
        75: "18.75rem",
        90: "22.5rem",
        94: "23.5rem",
        95: "23.75rem",
        100: "25rem",
        115: "28.75rem",
        125: "31.25rem",
        132.5: "33.125rem",
        150: "37.5rem",
        171.5: "42.875rem",
        180: "45rem",
        187.5: "46.875rem",
        203: "50.75rem",
        230: "57.5rem",
        242.5: "60.625rem",
      },
      maxWidth: {
        2.5: "0.625rem",
        3: "0.75rem",
        4: "1rem",
        7: "1.75rem",
        9: "2.25rem",
        10: "2.5rem",
        10.5: "2.625rem",
        11: "2.75rem",
        13: "3.25rem",
        14: "3.5rem",
        15: "3.75rem",
        16: "4rem",
        22.5: "5.625rem",
        25: "6.25rem",
        30: "7.5rem",
        34: "8.5rem",
        35: "8.75rem",
        40: "10rem",
        42.5: "10.625rem",
        44: "11rem",
        45: "11.25rem",
        60: "15rem",
        70: "17.5rem",
        90: "22.5rem",
        94: "23.5rem",
        125: "31.25rem",
        132.5: "33.125rem",
        142.5: "35.625rem",
        150: "37.5rem",
        180: "45rem",
        203: "50.75rem",
        230: "57.5rem",
        242.5: "60.625rem",
        270: "67.5rem",
        280: "70rem",
        292.5: "73.125rem",
      },
      maxHeight: {
        35: "8.75rem",
        70: "17.5rem",
        90: "22.5rem",
        550: "34.375rem",
        300: "18.75rem",
      },
      minWidth: {
        22.5: "5.625rem",
        42.5: "10.625rem",
        47.5: "11.875rem",
        75: "18.75rem",
      },
      zIndex: {
        999999: "999999",
        99999: "99999",
        9999: "9999",
        999: "999",
        99: "99",
        9: "9",
        1: "1",
      },
      opacity: {
        65: ".65",
      },
      aspectRatio: {
        "4/3": "4 / 3",
        "21/9": "21 / 9",
      },
      backgroundImage: {
        video: "url('../images/video/video.png')",
      },
      content: {
        "icon-copy": 'url("../images/icon/icon-copy-alt.svg")',
      },
      transitionProperty: { width: "width", stroke: "stroke" },
      borderWidth: {
        6: "6px",
        10: "10px",
        12: "12px",
      },
      boxShadow: {
        default: "0px 8px 13px -3px rgba(0, 0, 0, 0.07)",
        card: "0px 1px 3px rgba(0, 0, 0, 0.12)",
        "card-2": "0px 1px 2px rgba(0, 0, 0, 0.05)",
        switcher:
            "0px 2px 4px rgba(0, 0, 0, 0.2), inset 0px 2px 2px #FFFFFF, inset 0px -1px 1px rgba(0, 0, 0, 0.1)",
        "switch-1": "0px 0px 5px rgba(0, 0, 0, 0.15)",
        1: "0px 1px 3px rgba(0, 0, 0, 0.08)",
        2: "0px 1px 4px rgba(0, 0, 0, 0.12)",
        3: "0px 1px 5px rgba(0, 0, 0, 0.14)",
        4: "0px 4px 10px rgba(0, 0, 0, 0.12)",
        5: "0px 1px 1px rgba(0, 0, 0, 0.15)",
        6: "0px 3px 15px rgba(0, 0, 0, 0.1)",
        7: "-5px 0 0 #313D4A, 5px 0 0 #313D4A",
        8: "1px 0 0 #313D4A, -1px 0 0 #313D4A, 0 1px 0 #313D4A, 0 -1px 0 #313D4A, 0 3px 13px rgb(0 0 0 / 8%)",
        9: "0px 2px 3px rgba(183, 183, 183, 0.5)",
        10: "0px 1px 2px 0px rgba(0, 0, 0, 0.10)",
        11: "0px 1px 3px 0px rgba(166, 175, 195, 0.40)",
        12: "0px 0.5px 3px 0px rgba(0, 0, 0, 0.18)",
        13: "0px 1px 3px 0px rgba(0, 0, 0, 0.08)",
        14: "0px 2px 3px 0px rgba(0, 0, 0, 0.10)",
      },
      dropShadow: {
        1: "0px 1px 0px #E2E8F0",
        2: "0px 1px 4px rgba(0, 0, 0, 0.12)",
        3: "0px 0px 4px rgba(0, 0, 0, 0.15)",
        4: "0px 0px 2px rgba(0, 0, 0, 0.2)",
        5: "0px 1px 5px rgba(0, 0, 0, 0.2)",
      },
      keyframes: {
        linspin: {
          "100%": { transform: "rotate(360deg)" },
        },
        easespin: {
          "12.5%": { transform: "rotate(135deg)" },
          "25%": { transform: "rotate(270deg)" },
          "37.5%": { transform: "rotate(405deg)" },
          "50%": { transform: "rotate(540deg)" },
          "62.5%": { transform: "rotate(675deg)" },
          "75%": { transform: "rotate(810deg)" },
          "87.5%": { transform: "rotate(945deg)" },
          "100%": { transform: "rotate(1080deg)" },
        },
        "left-spin": {
          "0%": { transform: "rotate(130deg)" },
          "50%": { transform: "rotate(-5deg)" },
          "100%": { transform: "rotate(130deg)" },
        },
        "right-spin": {
          "0%": { transform: "rotate(-130deg)" },
          "50%": { transform: "rotate(5deg)" },
          "100%": { transform: "rotate(-130deg)" },
        },
        rotating: {
          "0%, 100%": { transform: "rotate(360deg)" },
          "50%": { transform: "rotate(0deg)" },
        },
        topbottom: {
          "0%, 100%": { transform: "translate3d(0, -100%, 0)" },
          "50%": { transform: "translate3d(0, 0, 0)" },
        },
        bottomtop: {
          "0%, 100%": { transform: "translate3d(0, 0, 0)" },
          "50%": { transform: "translate3d(0, -100%, 0)" },
        },
        line: {
          "0%, 100%": { transform: "translateY(0)" },
          "50%": { transform: "translateY(100%)" },
        },
        "line-revert": {
          "0%, 100%": { transform: "translateY(100%)" },
          "50%": { transform: "translateY(0)" },
        },
      },
      animation: {
        linspin: "linspin 1568.2353ms linear infinite",
        easespin: "easespin 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both",
        "left-spin":
            "left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both",
        "right-spin":
            "right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both",
        "ping-once": "ping 5s cubic-bezier(0, 0, 0.2, 1)",
        rotating: "rotating 30s linear infinite",
        topbottom: "topbottom 60s infinite alternate linear",
        bottomtop: "bottomtop 60s infinite alternate linear",
        "spin-1.5": "spin 1.5s linear infinite",
        "spin-2": "spin 2s linear infinite",
        "spin-3": "spin 3s linear infinite",
        line1: "line 10s infinite linear",
        line2: "line-revert 8s infinite linear",
        line3: "line 7s infinite linear",
      },
    },
  },
  plugins: [],
};
export default config;

style.cssの適用

tailadminのデザイン適用のためにcssファイルも作成します。

@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap");

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
    body {
        @apply relative z-1 bg-whiten text-base font-normal text-body;
    }
}

@layer components {
}

@layer utilities {
    /* Chrome, Safari and Opera */
    .no-scrollbar::-webkit-scrollbar {
        display: none;
    }

    .no-scrollbar {
        -ms-overflow-style: none; /* IE and Edge */
        scrollbar-width: none; /* Firefox */
    }

    .chat-height {
        @apply h-[calc(100vh_-_8.125rem)] lg:h-[calc(100vh_-_5.625rem)];
    }

    .inbox-height {
        @apply h-[calc(100vh_-_8.125rem)] lg:h-[calc(100vh_-_5.625rem)];
    }
}

/* third-party libraries CSS */

.tableCheckbox:checked ~ div span {
    @apply opacity-100;
}

.tableCheckbox:checked ~ div {
    @apply border-primary bg-primary;
}

.apexcharts-legend-text {
    @apply !text-body dark:!text-bodydark;
}

.apexcharts-text {
    @apply !fill-body dark:!fill-bodydark;
}

.apexcharts-xcrosshairs {
    @apply !fill-stroke dark:!fill-strokedark;
}

.apexcharts-gridline {
    @apply !stroke-stroke dark:!stroke-strokedark;
}

.apexcharts-series.apexcharts-pie-series path {
    @apply dark:!stroke-transparent;
}

.apexcharts-legend-series {
    @apply !inline-flex gap-1.5;
}

.apexcharts-tooltip.apexcharts-theme-light {
    @apply dark:!border-strokedark dark:!bg-boxdark;
}

.apexcharts-tooltip.apexcharts-theme-light .apexcharts-tooltip-title {
    @apply dark:!border-strokedark dark:!bg-meta-4;
}

.apexcharts-xaxistooltip,
.apexcharts-yaxistooltip {
    @apply dark:!border-meta-4 dark:!bg-meta-4 dark:!text-bodydark1;
}

.apexcharts-xaxistooltip-bottom:after {
    @apply !border-b-gray dark:!border-b-meta-4;
}

.apexcharts-xaxistooltip-bottom:before {
    @apply !border-b-gray dark:!border-b-meta-4;
}

.apexcharts-xaxistooltip-bottom {
    @apply !rounded !border-none !bg-gray !text-xs !font-medium !text-black dark:!text-white;
}

.apexcharts-tooltip-series-group {
    @apply !pl-1.5;
}

.flatpickr-wrapper {
    @apply w-full;
}

.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
    @apply !fill-primary;
}

.flatpickr-calendar.arrowTop:before {
    @apply dark:!border-b-boxdark;
}

.flatpickr-calendar.arrowTop:after {
    @apply dark:!border-b-boxdark;
}

.flatpickr-calendar {
    @apply !p-6 dark:!bg-boxdark dark:!text-bodydark dark:!shadow-8 2xsm:!w-auto;
}

.flatpickr-day {
    @apply dark:!text-bodydark dark:hover:!border-meta-4 dark:hover:!bg-meta-4;
}

.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
    @apply !top-7 dark:!fill-white dark:!text-white;
}

.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
    @apply !left-7;
}

.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
    @apply !right-7;
}

span.flatpickr-weekday,
.flatpickr-months .flatpickr-month {
    @apply dark:!fill-white dark:!text-white;
}

.flatpickr-day.inRange {
    box-shadow: -5px 0 0 #f3f4f6, 5px 0 0 #f3f4f6 !important;
    @apply dark:!shadow-7;
}

.flatpickr-day.inRange,
.flatpickr-day.prevMonthDay.inRange,
.flatpickr-day.nextMonthDay.inRange,
.flatpickr-day.today.inRange,
.flatpickr-day.prevMonthDay.today.inRange,
.flatpickr-day.nextMonthDay.today.inRange,
.flatpickr-day:hover,
.flatpickr-day.prevMonthDay:hover,
.flatpickr-day.nextMonthDay:hover,
.flatpickr-day:focus,
.flatpickr-day.prevMonthDay:focus,
.flatpickr-day.nextMonthDay:focus {
    @apply !border-[#F3F4F6] !bg-[#F3F4F6] dark:!border-meta-4 dark:!bg-meta-4;
}

.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.selected,
.flatpickr-day.endRange {
    @apply dark:!text-white;
}

.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
.flatpickr-day.selected.inRange,
.flatpickr-day.startRange.inRange,
.flatpickr-day.endRange.inRange,
.flatpickr-day.selected:focus,
.flatpickr-day.startRange:focus,
.flatpickr-day.endRange:focus,
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover,
.flatpickr-day.selected.prevMonthDay,
.flatpickr-day.startRange.prevMonthDay,
.flatpickr-day.endRange.prevMonthDay,
.flatpickr-day.selected.nextMonthDay,
.flatpickr-day.startRange.nextMonthDay,
.flatpickr-day.endRange.nextMonthDay {
    background: #3c50e0;
    @apply !border-primary !bg-primary hover:!border-primary hover:!bg-primary;
}

.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n + 1)),
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n + 1)),
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n + 1)) {
    box-shadow: -10px 0 0 #3c50e0;
}

.map-btn .jvm-zoom-btn {
    @apply flex h-7.5 w-7.5 items-center justify-center rounded border border-stroke bg-white px-0 pb-0.5 pt-0 text-2xl leading-none text-body hover:border-primary hover:bg-primary hover:text-white dark:border-strokedark dark:bg-meta-4 dark:text-bodydark dark:hover:border-primary dark:hover:bg-primary dark:hover:text-white;
}

.mapOne .jvm-zoom-btn {
    @apply !bottom-0 !left-auto !top-auto;
}

.mapOne .jvm-zoom-btn.jvm-zoomin {
    @apply !right-10;
}

.mapOne .jvm-zoom-btn.jvm-zoomout {
    @apply !right-0;
}

.taskCheckbox:checked ~ .box span {
    @apply opacity-100;
}

.taskCheckbox:checked ~ p {
    @apply line-through;
}

.taskCheckbox:checked ~ .box {
    @apply border-primary bg-primary dark:border-primary;
}

.custom-input-date::-webkit-calendar-picker-indicator {
    background: transparent;
}

input[type="search"]::-webkit-search-cancel-button {
    @apply appearance-none;
}

.custom-input-date::-webkit-calendar-picker-indicator {
    background-position: center;
    background-repeat: no-repeat;
    background-size: 20px;
}

[x-cloak] {
    display: none !important;
}

パンくずリストの作成

メイン要素上のタイトルと、パンくずリストの表示部分を作成します。

import Link from "next/link";
interface BreadcrumbProps {
  pageName: string;
}
const Breadcrumb = ({ pageName }: BreadcrumbProps) => {
  return (
    <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
      <h2 className="text-title-md2 font-semibold text-black dark:text-white">
        {pageName}
      </h2>

      <nav>
        <ol className="flex items-center gap-2">
          <li>
            <Link className="font-medium" href="/">
              Dashboard /
            </Link>
          </li>
          <li className="font-medium text-primary">{pageName}</li>
        </ol>
      </nav>
    </div>
  );
};

export default Breadcrumb;

ローディング画面作成

ローディング時の背景部分を作るコンポーネントです。

const Loader = () => {
  return (
    <div className="flex h-screen items-center justify-center bg-white dark:bg-black">
      <div className="h-16 w-16 animate-spin rounded-full border-4 border-solid border-primary border-t-transparent"></div>
    </div>
  );
};

export default Loader;

ヘッダー作成

ヘッダー部分を作成します。
元のtailadminのヘッダーとは違い、ログインボタンのみ表示しています。

"use client"
import Link from "next/link";
import {createClient} from "@/utils/supabase/client";
import {Dispatch, SetStateAction} from "react";
import {User} from "@supabase/gotrue-js";


export default function Header(props: {
    sidebarOpen: string | boolean | undefined;
    setSidebarOpen: (arg0: boolean) => void;
    currentUser: User | null,
    setCurrentUser: Dispatch<SetStateAction<User | null>>;
}) {
    const supabase = createClient()

    const handleSignout = async () => {
        const { error } = await supabase.auth.signOut()

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

        props.setCurrentUser(null)
    }

    return (
        <header className="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
            <div className="flex flex-grow items-center justify-between px-4 py-4 shadow-2 md:px-6 2xl:px-11">
                <div className="flex items-center gap-2 sm:gap-4 lg:hidden">
                    {/* <!-- Hamburger Toggle BTN --> */}
                    <button
                        aria-controls="sidebar"
                        onClick={(e) => {
                            e.stopPropagation();
                            props.setSidebarOpen(!props.sidebarOpen);
                        }}
                        className="z-99999 block rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
                    >
            <span className="relative block h-5.5 w-5.5 cursor-pointer">
              <span className="du-block absolute right-0 h-full w-full">
                <span
                    className={`relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm bg-black delay-[0] duration-200 ease-in-out dark:bg-white ${
                        !props.sidebarOpen && "!w-full delay-300"
                    }`}
                ></span>
                <span
                    className={`relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm bg-black delay-150 duration-200 ease-in-out dark:bg-white ${
                        !props.sidebarOpen && "delay-400 !w-full"
                    }`}
                ></span>
                <span
                    className={`relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm bg-black delay-200 duration-200 ease-in-out dark:bg-white ${
                        !props.sidebarOpen && "!w-full delay-500"
                    }`}
                ></span>
              </span>
              <span className="absolute right-0 h-full w-full rotate-45">
                <span
                    className={`absolute left-2.5 top-0 block h-full w-0.5 rounded-sm bg-black delay-300 duration-200 ease-in-out dark:bg-white ${
                        !props.sidebarOpen && "!h-0 !delay-[0]"
                    }`}
                ></span>
                <span
                    className={`delay-400 absolute left-0 top-2.5 block h-0.5 w-full rounded-sm bg-black duration-200 ease-in-out dark:bg-white ${
                        !props.sidebarOpen && "!h-0 !delay-200"
                    }`}
                ></span>
              </span>
            </span>
                    </button>
                    {/* <!-- Hamburger Toggle BTN --> */}
                </div>

                <div className="flex items-center gap-3 2xsm:gap-7">
                    {props.currentUser?.id ? (
                        <button
                            type="button"
                            onClick={handleSignout}
                            className="w-24 py-2 px-4 rounded-md no-underline bg-gray hover:bg-graydark"
                        >
                            ログアウト
                        </button>
                    ) : (
                        <Link
                            href="/auth/signin"
                            className="w-24 py-2 px-4 rounded-md no-underline bg-gray hover:bg-graydark"
                        >
                            ログイン
                        </Link>
                    )}

                </div>
            </div>
        </header>
    );
};

デフォルトのレイアウト部分作成

ヘッダーとサイドメニュー、メイン画面をまとめるレイアウト全体を作成するコンポーネントを作成します。

"use client";
import React, {useState, useEffect} from "react";
import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header";
import {createClient} from "@/utils/supabase/client";
import {User} from "@supabase/gotrue-js";

export default function DefaultLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const supabase = createClient()
  const [currentUser, setCurrentUser] = useState<User | null>(null)
  useEffect(() => {
    (async () => {
      const { data: { user } } = await supabase.auth.getUser()
      setCurrentUser(user)
    })()
  }, []);

  const [sidebarOpen, setSidebarOpen] = useState(false);
  return (
    <>
      {/* <!-- ===== Page Wrapper Start ===== --> */}
      <div className="flex h-screen overflow-hidden">
        {/* <!-- ===== Sidebar Start ===== --> */}
        <Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} currentUser={currentUser} />
        {/* <!-- ===== Sidebar End ===== --> */}

        {/* <!-- ===== Content Area Start ===== --> */}
        <div className="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
          {/* <!-- ===== Header Start ===== --> */}
          <Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} currentUser={currentUser} setCurrentUser={setCurrentUser} />
          {/* <!-- ===== Header End ===== --> */}

          {/* <!-- ===== Main Content Start ===== --> */}
          <main>
            <div className="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
              {children}
            </div>
          </main>
          {/* <!-- ===== Main Content End ===== --> */}
        </div>
        {/* <!-- ===== Content Area End ===== --> */}
      </div>
      {/* <!-- ===== Page Wrapper End ===== --> */}
    </>
  );
}

サイドバー作成

tailadminのサイドバー部分を、必要なリンクに変更して修正したものになります。

"use client";

import React, { useEffect, useRef, useState} from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import {User} from "@supabase/gotrue-js";

interface SidebarProps {
  sidebarOpen: boolean;
  setSidebarOpen: (arg: boolean) => void;
  currentUser: User | null,
}

const Sidebar = ({ sidebarOpen, setSidebarOpen, currentUser }: SidebarProps) => {

  const pathname = usePathname();

  const trigger = useRef<any>(null);
  const sidebar = useRef<any>(null);

  let storedSidebarExpanded = "true";

  const [sidebarExpanded, setSidebarExpanded] = useState(
    storedSidebarExpanded === "true",
  );

  // close on click outside
  useEffect(() => {
    const clickHandler = ({ target }: MouseEvent) => {
      if (!sidebar.current || !trigger.current) return;
      if (
        !sidebarOpen ||
        sidebar.current.contains(target) ||
        trigger.current.contains(target)
      )
        return;
      setSidebarOpen(false);
    };
    document.addEventListener("click", clickHandler);
    return () => document.removeEventListener("click", clickHandler);
  });

  // close if the esc key is pressed
  useEffect(() => {
    const keyHandler = ({ key }: KeyboardEvent) => {
      if (!sidebarOpen || key !== "Escape") return;
      setSidebarOpen(false);
    };
    document.addEventListener("keydown", keyHandler);
    return () => document.removeEventListener("keydown", keyHandler);
  });

  useEffect(() => {
    localStorage.setItem("sidebar-expanded", sidebarExpanded.toString());
    if (sidebarExpanded) {
      document.querySelector("body")?.classList.add("sidebar-expanded");
    } else {
      document.querySelector("body")?.classList.remove("sidebar-expanded");
    }
  }, [sidebarExpanded]);

  return (
    <aside
      ref={sidebar}
      className={`absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-black duration-300 ease-linear dark:bg-boxdark lg:static lg:translate-x-0 ${
        sidebarOpen ? "translate-x-0" : "-translate-x-full"
      }`}
    >
      {/* <!-- SIDEBAR HEADER --> */}
      <div className="flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5">
        <Link href="/">
          求人アプリ管理画面
        </Link>

        <button
          ref={trigger}
          onClick={() => setSidebarOpen(!sidebarOpen)}
          aria-controls="sidebar"
          aria-expanded={sidebarOpen}
          className="block lg:hidden"
        >
          <svg
            className="fill-current"
            width="20"
            height="18"
            viewBox="0 0 20 18"
            fill="none"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path
              d="M19 8.175H2.98748L9.36248 1.6875C9.69998 1.35 9.69998 0.825 9.36248 0.4875C9.02498 0.15 8.49998 0.15 8.16248 0.4875L0.399976 8.3625C0.0624756 8.7 0.0624756 9.225 0.399976 9.5625L8.16248 17.4375C8.31248 17.5875 8.53748 17.7 8.76248 17.7C8.98748 17.7 9.17498 17.625 9.36248 17.475C9.69998 17.1375 9.69998 16.6125 9.36248 16.275L3.02498 9.8625H19C19.45 9.8625 19.825 9.4875 19.825 9.0375C19.825 8.55 19.45 8.175 19 8.175Z"
              fill=""
            />
          </svg>
        </button>
      </div>
      {/* <!-- SIDEBAR HEADER --> */}

      {currentUser ? (
          <div className="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
            {/* <!-- Sidebar Menu --> */}
            <nav className="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
              {/* <!-- Menu Group --> */}
              <div>
                <h3 className="mb-4 ml-4 text-sm font-semibold text-bodydark2">
                  MENU
                </h3>

                <ul className="mb-6 flex flex-col gap-1.5">
                  {/* <!-- Menu Item 企業一覧 --> */}
                  <li>
                    <Link
                        href="/companylist"
                        className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
                            pathname.includes("calendar") &&
                            "bg-graydark dark:bg-meta-4"
                        }`}
                    >
                      <span className="material-symbols-outlined">apartment</span>
                      企業一覧
                    </Link>
                  </li>
                  {/* <!-- Menu Item 企業一覧 --> */}

                  {/* <!-- Menu Item 求人一覧 --> */}
                  <li>
                    <Link
                        href="/joblist"
                        className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
                            pathname.includes("calendar") &&
                            "bg-graydark dark:bg-meta-4"
                        }`}
                    >
                      <span className="material-symbols-outlined">work</span>
                      求人一覧
                    </Link>
                  </li>
                  {/* <!-- Menu Item 求人一覧 --> */}

                  {/* <!-- Menu Item 求職者一覧 --> */}
                  <li>
                    <Link
                        href="/jobseekerlist"
                        className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
                            pathname.includes("profile") && "bg-graydark dark:bg-meta-4"
                        }`}
                    >
                      <span className="material-symbols-outlined">group</span>
                      求職者一覧
                    </Link>
                  </li>
                  {/* <!-- Menu Item 求職者一覧 --> */}

                  {/* <!-- Menu Item チャット --> */}
                  <li>
                    <Link
                        href="/chatlist"
                        className={`group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium text-bodydark1 duration-300 ease-in-out hover:bg-graydark dark:hover:bg-meta-4 ${
                            pathname.includes("tables") && "bg-graydark dark:bg-meta-4"
                        }`}
                    >
                      <span className="material-symbols-outlined">forum</span>
                      チャット一覧
                    </Link>
                  </li>
                  {/* <!-- Menu Item チャット --> */}
                </ul>
              </div>

            </nav>
            {/* <!-- Sidebar Menu --> */}
          </div>
      ) : null}
    </aside>
  );
};

export default Sidebar;

求人アプリ側から必要なファイルの移植

https://github.com/TodoONada/Career-Matching
上記リポジトリ内にある、

  • DefaultInput.tsx

  • DefaultSelect.tsx

  • DefaultTextarea.tsx

  • DetailList.tsx

  • FileUploader.tsx

  • FileUploadInput.tsx

  • SubmitButton.tsx

  • DatabaseType.ts

  • inputType.ts

をコピーして添付画像のような構成で配置してください

ログイン/ログアウト機能実装

ここまでで準備が終わったのでログイン/ログアウトを実装します。

親ページの作成

`app/auth/signin/page.tsx`を作成します。

import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import {Metadata} from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import SigninForm from "@/components/Signin/SigninForm";

export const metadata: Metadata = {
    title: "求人アプリ管理画面 | ログインページ",
};

export default function SignIn() {

    return (
        <DefaultLayout>
            <Breadcrumb pageName="ログイン"/>

            <div
                className="rounded-sm border border-stroke bg-white shadow-default dark:border-strokedark dark:bg-boxdark">
                <div className="flex flex-wrap items-center">
                    <div className="w-full border-stroke dark:border-strokedark xl:w-1/2 xl:border-l-2">
                        <div className="w-full p-4 sm:p-12.5 xl:p-17.5">
                            <h2 className="mb-9 text-2xl font-bold text-black dark:text-white sm:text-title-xl2">
                                管理画面にログイン
                            </h2>
                            <SigninForm></SigninForm>
                        </div>
                    </div>
                </div>
            </div>
        </DefaultLayout>
    );
};

フォーム部分作成

フォーム部分をコンポーネントに切り分けていたので、こちらを作成します。

"use client"

import React, {FormEventHandler} from "react";
import {createClient} from "@/utils/supabase/client";
import {Database} from "@/types/supabase";
import { useRouter } from 'next/navigation'

export default function SigninForm() {
    const supabase = createClient();
    const router = useRouter()

    const getUserType = async (userID: string) => {
        const {data, error} = await supabase.from("mst_user_type")
            .select()
            .eq("user_uid", userID)

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

        console.log(data)

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

    const checkUserType = async () => {
        let result = false;
        const {data: {user}} = await supabase.auth.getUser()

        if (user != null) {
            const userTypes = await getUserType(user.id)
            for (let i = 0; i < userTypes.length; i++) {

                if (userTypes[i]["user_uid"] === user.id && userTypes[i]["user_type"] === "admin") {
                    result = true;
                    break;
                }
            }
        }

        return result;
    }


    const handleSubmit: FormEventHandler<HTMLFormElement> = async (event) => {
        event.preventDefault();
        const form = new FormData(event.currentTarget);
        const email = form.get("email") as string;
        const password = form.get("password") as string;

        const {error} = await supabase.auth.signInWithPassword({
            email,
            password,
        });

        if (error) {
            console.log(error)
            router.replace(`/auth/signin?message=${error.message}`)
        }

        const isAdmin = await checkUserType()

        if (!isAdmin) {
            console.log("you are not admin")
            const { error } = await supabase.auth.signOut()

            router.replace(`/auth/signin?message=notAdmin`)
            return
        }

        router.replace(`/`)
    };

    return (<form onSubmit={handleSubmit}>
            <div className="mb-4">
                <label className="mb-2.5 block font-medium text-black dark:text-white">
                    Email
                </label>
                <div className="relative">
                    <input
                        name="email"
                        type="email"
                        placeholder="Enter your email"
                        className="w-full rounded-lg border border-stroke bg-transparent py-4 pl-6 pr-10 text-black outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary"
                    />

                    <span className="absolute right-4 top-4">
                      <svg
                          className="fill-current"
                          width="22"
                          height="22"
                          viewBox="0 0 22 22"
                          fill="none"
                          xmlns="http://www.w3.org/2000/svg"
                      >
                        <g opacity="0.5">
                          <path
                              d="M19.2516 3.30005H2.75156C1.58281 3.30005 0.585938 4.26255 0.585938 5.46567V16.6032C0.585938 17.7719 1.54844 18.7688 2.75156 18.7688H19.2516C20.4203 18.7688 21.4172 17.8063 21.4172 16.6032V5.4313C21.4172 4.26255 20.4203 3.30005 19.2516 3.30005ZM19.2516 4.84692C19.2859 4.84692 19.3203 4.84692 19.3547 4.84692L11.0016 10.2094L2.64844 4.84692C2.68281 4.84692 2.71719 4.84692 2.75156 4.84692H19.2516ZM19.2516 17.1532H2.75156C2.40781 17.1532 2.13281 16.8782 2.13281 16.5344V6.35942L10.1766 11.5157C10.4172 11.6875 10.6922 11.7563 10.9672 11.7563C11.2422 11.7563 11.5172 11.6875 11.7578 11.5157L19.8016 6.35942V16.5688C19.8703 16.9125 19.5953 17.1532 19.2516 17.1532Z"
                              fill=""
                          />
                        </g>
                      </svg>
                    </span>
                </div>
            </div>

            <div className="mb-6">
                <label className="mb-2.5 block font-medium text-black dark:text-white">
                    Password
                </label>
                <div className="relative">
                    <input
                        name="password"
                        type="password"
                        placeholder="6+ Characters, 1 Capital letter"
                        className="w-full rounded-lg border border-stroke bg-transparent py-4 pl-6 pr-10 outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:text-white dark:focus:border-primary"
                    />

                    <span className="absolute right-4 top-4">
                      <svg
                          className="fill-current"
                          width="22"
                          height="22"
                          viewBox="0 0 22 22"
                          fill="none"
                          xmlns="http://www.w3.org/2000/svg"
                      >
                        <g opacity="0.5">
                          <path
                              d="M16.1547 6.80626V5.91251C16.1547 3.16251 14.0922 0.825009 11.4797 0.618759C10.0359 0.481259 8.59219 0.996884 7.52656 1.95938C6.46094 2.92188 5.84219 4.29688 5.84219 5.70626V6.80626C3.84844 7.18438 2.33594 8.93751 2.33594 11.0688V17.2906C2.33594 19.5594 4.19219 21.3813 6.42656 21.3813H15.5016C17.7703 21.3813 19.6266 19.525 19.6266 17.2563V11C19.6609 8.93751 18.1484 7.21876 16.1547 6.80626ZM8.55781 3.09376C9.31406 2.40626 10.3109 2.06251 11.3422 2.16563C13.1641 2.33751 14.6078 3.98751 14.6078 5.91251V6.70313H7.38906V5.67188C7.38906 4.70938 7.80156 3.78126 8.55781 3.09376ZM18.1141 17.2906C18.1141 18.7 16.9453 19.8688 15.5359 19.8688H6.46094C5.05156 19.8688 3.91719 18.7344 3.91719 17.325V11.0688C3.91719 9.52189 5.15469 8.28438 6.70156 8.28438H15.2953C16.8422 8.28438 18.1141 9.52188 18.1141 11V17.2906Z"
                              fill=""
                          />
                          <path
                              d="M10.9977 11.8594C10.5852 11.8594 10.207 12.2031 10.207 12.65V16.2594C10.207 16.6719 10.5508 17.05 10.9977 17.05C11.4102 17.05 11.7883 16.7063 11.7883 16.2594V12.6156C11.7883 12.2031 11.4102 11.8594 10.9977 11.8594Z"
                              fill=""
                          />
                        </g>
                      </svg>
                    </span>
                </div>
            </div>

            <div className="mb-5">
                <input
                    type="submit"
                    value="Sign In"
                    className="w-full cursor-pointer rounded-lg border border-primary bg-primary p-4 text-white transition hover:bg-opacity-90"
                />
            </div>
        </form>
    )
}

企業一覧実装


こちらの企業一覧画面を作成します。
UI自体は下記のテーブルコンポーネントをもとに作成しています。
https://github.com/TailAdmin/free-nextjs-admin-dashboard/blob/main/src/components/Tables/TableThree.tsx

親ページ作成

`app/companylist/page.tsx`を作成します。

import { Metadata } from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import CompanyList from "@/components/CompanyList/CompanyList";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | 企業一覧",
};

export default function CompanyListPage() {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="企業一覧"/>
                <CompanyList></CompanyList>
            </DefaultLayout>
        </>
    );
}

企業一覧のコンポーネント作成

企業一覧の取得を行い、Tableに渡す`components/CompanyList/CompanyList.tsx`を作成します。

"use client"

import CompanyListTable from "@/components/CompanyList/CompanyListTable";
import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import {Database} from "@/types/supabase";

export default function CompanyList() {
    const supabase = createClient()
    const [companyData, setCompanyData] = useState<any[]>([])

    const getCompanyData = async () => {
        const {data, error} = await supabase.from("mst_company").select()

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

        const tmpData = data as Database["public"]["Tables"]["mst_company"]["Row"][]

        const industryData = await getIndustryData()
        let result: any[] = []
        for (let i = 0; i < tmpData.length; i++) {
            for (let j = 0; j < industryData.length; j++) {
                if (tmpData[i].industry_id_1 === industryData[j].id) {
                    result.push({
                        user_uid: tmpData[i].user_uid,
                        name: tmpData[i].name,
                        address1: tmpData[i].address1,
                        address2: tmpData[i].address2,
                        industry: industryData[j].industry,
                        employee: tmpData[i].employee,
                        established_at: tmpData[i].established_at,
                    })
                }
            }
        }


        setCompanyData(result)
    }

    const getIndustryData = async () => {
        const {data, error} = await supabase.from("mst_industry").select()
        if (error) {
            return []
        }

        console.log(data)

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

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

    return (
        <div>
            <CompanyListTable companyData={companyData}></CompanyListTable>
        </div>
    )
}

企業一覧表示用テーブル作成

CompanyList.tsxからデータを渡され、実際に企業一覧を表示するコンポーネントを作成します。

"use client"
import {useRouter} from "next/navigation";

type Props = {
  companyData: any[]
}

const CompanyListTable = ({companyData}: Props) => {
  const router = useRouter()
  const seeCompanyDetail = async (id: string) => {
    router.replace("/companydetail?id=" + id)
  }
  return (
    <div className="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
      <div className="max-w-full overflow-x-auto">
        <table className="w-full table-auto">
          <thead>
          <tr className="bg-gray-2 text-left dark:bg-meta-4">
            <th className="min-w-[220px] px-4 py-4 font-medium text-black dark:text-white xl:pl-11">
              会社名(住所)
            </th>
            <th className="min-w-[150px] px-4 py-4 font-medium text-black dark:text-white">
              業界
            </th>
            <th className="min-w-[120px] px-4 py-4 font-medium text-black dark:text-white">
              従業員数
            </th>
            <th className="px-4 py-4 font-medium text-black dark:text-white">
              設立
            </th>
          </tr>
          </thead>
          <tbody>
          {companyData.map((item, index) => (
              <tr key={index} onClick={() => seeCompanyDetail(item.user_uid)}>
                <td className="border-b border-[#eee] px-4 py-5 pl-9 dark:border-strokedark xl:pl-11">
                  <h5 className="font-medium text-black dark:text-white">
                    {item.name}
                  </h5>
                  <p className="text-sm"> {item.address1} {item.address2}</p>
                </td>
                <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                  <p className="text-black dark:text-white">
                    {item.industry}
                  </p>
                </td>
                <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                  <p className="text-black dark:text-white">
                    {item.employee}
                  </p>
                </td>
                <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                  <p className="text-black dark:text-white">
                    {item.established_at}
                  </p>
                </td>
              </tr>
          ))}
          </tbody>
        </table>
      </div>
    </div>
  );
};

export default CompanyListTable;

企業詳細ページ実装

企業一覧からクリックして遷移できる企業詳細ページを実装します。
企業情報の修正を行うことができます。

親ページ作成

import {Metadata} from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import CompanyDetail from "@/components/CompanyDetail/CompanyDetail";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | 企業詳細",
};

export default function CompanyDetailPage({searchParams}: {
    searchParams: { id: string };
}) {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="企業詳細"/>
                <CompanyDetail companyID={searchParams.id}></CompanyDetail>
            </DefaultLayout>
        </>
    );
}

企業詳細コンポーネント作成

企業詳細ページのデータ取得・表示・修正機能を行うためのコンポーネントを作成します。

"use client"

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

type Props = {
    companyID: string,
}
export default function CompanyDetail({companyID}: Props) {
    const [message, setMessage] = useState("");
    const supabase = createClient();
    const [industryData, setIndustryData] = useState<
        Database["public"]["Tables"]["mst_industry"]["Row"][]
    >([]);

    // フォームデータを保持する
    const [companyname, setCompanyname] = useState("");
    const [zipcode, setZipcode] = useState("");
    const [address1, setAddress1] = useState("");
    const [address2, setAddress2] = useState("");
    const [phone, setPhone] = useState<string | null>(null);
    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_id_1, setIndustry_id_1] = useState<string | null>(null);
    const [industry_id_2, setIndustry_id_2] = useState<string | null>(null);
    const [industry_id_3, setIndustry_id_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(() => {
        getIndustyData();
        setData();
    }, [companyID]);

    const setData = async () => {
        const {data} = 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);
            }
            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) {
                setIndustry_id_1(company_data.industry_id_1);
            }
            if (company_data.industry_id_2 != null) {
                setIndustry_id_2(company_data.industry_id_2);
            }
            if (company_data.industry_id_3 != null) {
                setIndustry_id_3(company_data.industry_id_3);
            }
            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 getIndustyData = 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;
        setIndustryData(fixed_data);
    };
    const onSubmit = async () => {
        const timeStamp = new Date().toISOString();

        const {error} = await supabase.from("mst_company").update({
            name: companyname,
            zipcode: zipcode,
            address1: address1,
            address2: address2,
            phone: phone,
            url: companyurl,
            capital: capital,
            employee: employee,
            annual_turnover: annual_turnover,
            established_at: established_at,
            industry_id_1: industry_id_1,
            industry_id_2: industry_id_2,
            industry_id_3: industry_id_3,
            company_image_url_1: company_image_url_1,
            company_image_url_2: company_image_url_2,
            company_image_url_3: company_image_url_3,
            company_image_url_4: company_image_url_4,
            company_image_url_5: company_image_url_5,
            updated_at: timeStamp,
        }).eq("user_uid", companyID)

        if (error) {
            if (error.message === "duplicate key value violates unique constraint \"mst_job_seeker_phone_key\"") {
                setMessage("保存に失敗しました。ご利用の電話番号は既に登録されています。")
                return;
            }

            setMessage("保存に失敗しました。");
            return;
        } else {
            setMessage("");
        }
    };

    return (
        <div className="bg-white flex-1 flex flex-col px-4 py-8 justify-center gap-2">
            <form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground">
                <h2>企業の登録情報</h2>
                <>
                    <DefaultInput name={"companyname"} value={companyname} setter={setCompanyname} isRequired={true}
                                  labelText={"会社名*"} placeholderText={"株式会社○○"}
                                  inputType={InputType.text}/>
                    <DefaultInput name={"zipcode"} value={zipcode} setter={setZipcode} isRequired={true}
                                  labelText={"郵便番号*"}
                                  placeholderText={"0000000 ※ハイフンなしでご入力ください"}
                                  inputType={InputType.text}/>
                    <DefaultInput name={"address1"} value={address1} setter={setAddress1} isRequired={true}
                                  labelText={"住所1*"}
                                  placeholderText={"東京都港区浜松町2丁目2番15号"} inputType={InputType.text}/>
                    <DefaultInput name={"address2"} value={address2} setter={setAddress2} isRequired={true}
                                  labelText={"住所2*"} placeholderText={"浜松町ダイヤビル2F"}
                                  inputType={InputType.text}/>
                    <DefaultInput name={"phone"} value={phone != null ? phone : undefined} setter={setPhone}
                                  isRequired={false}
                                  labelText={"電話番号"}
                                  placeholderText={"00000000000 ※ハイフンなしでご入力ください"}
                                  inputType={InputType.text}/>
                    <DefaultInput name={"companyurl"} value={companyurl != null ? companyurl : undefined}
                                  setter={setCompanyurl} isRequired={false}
                                  labelText={"ホームページURL"} placeholderText={"https://example.com"}
                                  inputType={InputType.text}/>
                    <DefaultInput name={"capital"} value={capital != null ? capital : undefined} setter={setCapital}
                                  isRequired={false}
                                  labelText={"資本金"} placeholderText={"100 ※単位は万円"}
                                  inputType={InputType.number}/>
                    <DefaultInput name={"employee"} value={employee != null ? employee : undefined}
                                  setter={setEmployee} isRequired={false}
                                  labelText={"従業員数*"} placeholderText={"50"} inputType={InputType.number}/>
                    <DefaultInput name={"annual_turnover"}
                                  value={annual_turnover != null ? annual_turnover : undefined}
                                  setter={setAnnual_turnover} isRequired={false}
                                  labelText={"年商"} placeholderText={"5000 ※単位は万円"}
                                  inputType={InputType.number}/>
                    <DefaultInput name={"established_at"} value={established_at ? established_at : ""}
                                  setter={setEstablishd_at} isRequired={false}
                                  labelText={"設立*"} placeholderText={""} inputType={InputType.date}/>

                    <DefaultSelect name={"industry_id_1"} value={industry_id_1 ? industry_id_1 : ""}
                                   setter={setIndustry_id_1} isRequired={false} labelText={"業界1*"}
                                   selectData={industryData} databaseType={DatabaseType.mst_industry}/>

                    <DefaultSelect name={"industry_id_2"} value={industry_id_2 ? industry_id_2 : ""}
                                   setter={setIndustry_id_2} isRequired={false} labelText={"業界2"}
                                   selectData={industryData} databaseType={DatabaseType.mst_industry}/>

                    <DefaultSelect name={"industry_id_3"} value={industry_id_3 ? industry_id_3 : ""}
                                   setter={setIndustry_id_3} isRequired={false} labelText={"業界3"}
                                   selectData={industryData} databaseType={DatabaseType.mst_industry}/>

                    {/*<FileUploadInput name={"company_image_url_1"}*/}
                    {/*                 value={company_image_url_1 != null ? company_image_url_1 : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"会社の画像1のURL"} placeholderText={"会社の画像1のURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setCompany_image_url_1}/>*/}

                    {/*<FileUploadInput name={"company_image_url_2"}*/}
                    {/*                 value={company_image_url_2 != null ? company_image_url_2 : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"会社の画像2のURL"} placeholderText={"会社の画像2のURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setCompany_image_url_2}/>*/}

                    {/*<FileUploadInput name={"company_image_url_3"}*/}
                    {/*                 value={company_image_url_3 != null ? company_image_url_3 : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"会社の画像3のURL"} placeholderText={"会社の画像3のURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setCompany_image_url_3}/>*/}
                    {/*<FileUploadInput name={"company_image_url_4"}*/}
                    {/*                 value={company_image_url_4 != null ? company_image_url_4 : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"会社の画像4のURL"} placeholderText={"会社の画像4のURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setCompany_image_url_4}/>*/}
                    {/*<FileUploadInput name={"company_image_url_5"}*/}
                    {/*                 value={company_image_url_5 != null ? company_image_url_5 : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"会社の画像5のURL"} placeholderText={"会社の画像5のURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setCompany_image_url_5}/>*/}
                </>
                <SubmitButton
                    formAction={onSubmit}
                    className="bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white mb-2"
                    pendingText="企業情報更新中..."
                >
                    保存
                </SubmitButton>
                {message !== "" && (
                    <p className="mt-4 p-4 bg-foreground/10 text-foreground text-center">
                        {message}
                    </p>
                )}
            </form>
        </div>
    );
}

求人一覧ページ作成

添付画像のように求人が一覧で見られるページを作成します。

親ページ作成

import { Metadata } from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import CompanyList from "@/components/CompanyList/CompanyList";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import JobList from "@/components/JobList/JobList";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | 求人一覧",
};

export default function JobListPage() {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="求人一覧"/>
                <JobList></JobList>
            </DefaultLayout>
        </>
    );
}

求人一覧のコンポーネント作成

求人一覧のデータ取得、Tableへのデータの受け渡しなどを行うコンポーネントを作成します。

"use client"

import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import {Database} from "@/types/supabase";
import {getFixedJobData} from "@/components/JobList/utils/JobDataUtil";
import JobListTable from "@/components/JobList/JobListTable";

export default function JobList() {
    const supabase = createClient()
    const [jobData, setJobData] = useState<any[]>([])

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

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

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

    return (
        <div>
            <JobListTable jobListData={jobData}/>
        </div>
    )
}

求人一覧の表示用テーブルの作成

JobList.tsxから受け渡されたデータを、実際に表示するためのコンポーネントです。

"use client"
import {useRouter} from "next/navigation";

type Props = {
    jobListData: any[]
}

const JobListTable = ({jobListData}: Props) => {
    const router = useRouter()
    const seeJobDetail = async (id: string) => {
        router.replace("/jobdetail?id=" + id)
    }
    return (
        <div className="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
            <div className="max-w-full overflow-x-auto">
                <table className="w-full table-auto">
                    <thead>
                    <tr className="bg-gray-2 text-left dark:bg-meta-4">
                        <th className="min-w-[220px] px-4 py-4 font-medium text-black dark:text-white xl:pl-11">
                            求人名
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            勤務地
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            雇用形態
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            年収
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            職種
                        </th>
                    </tr>
                    </thead>
                    <tbody>
                    {jobListData.map((item, index) => (
                        <tr key={index} onClick={() => seeJobDetail(item.id)}>
                            <td className="border-b border-[#eee] px-4 py-5 pl-9 dark:border-strokedark xl:pl-11">
                                <h5 className="font-medium text-black dark:text-white">
                                    {item.name}
                                </h5>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.work_location}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.employment_class}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.annual_income}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.occupation}
                                </p>
                            </td>
                        </tr>
                    ))}
                    </tbody>
                </table>
            </div>
        </div>
    );
};

export default JobListTable;

求人一覧用のutilファイル作成

求人一覧のテーブルのみでは取得できない情報もあるため、こちらのutilファイルの中で整形したデータを取得するようにしています。

"use client"

import {Database} from "@/types/supabase";
import {createClient} from "@/utils/supabase/client";

export async function getFixedJobData(data: Database["public"]["Tables"]["trn_job"]["Row"][]) {
    const supabase = createClient();
    const getWorkLocationData = async () => {
        const {data, error} = await supabase.from("mst_work_location").select()
        if (error) {
            console.log(error);
            return;
        }
        return data as Database["public"]["Tables"]["mst_work_location"]["Row"][]
    }

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

    const result: any[] = []
    const workLocationData = await getWorkLocationData();
    const occupationData = await getOccupationData();

    for (let i = 0; i < data.length; i++) {
        let workLocationStr = ""
        for (let j = 0; j < workLocationData?.length!; j++) {
            if (data[i]["work_location"] === workLocationData![j]["id"]) {
                workLocationStr = workLocationData![j]["work_location"]
            }
        }

        let occupationStr = ""
        for (let j = 0; j < occupationData?.length!; j++) {
            if (data[i]["occupation_id"] === occupationData![j]["id"]) {
                occupationStr = occupationData![j]["occupation"]
            }
        }
        result.push({
            "id" : data[i]["id"],
            "name" : data[i]["name"],
            "work_location" : workLocationStr,
            "employment_class": data[i]["employment_class"],
            "annual_income": data[i]["annual_income"],
            "qualification": data[i]["qualification"],
            "required_skills": data[i]["required_skills"],
            "occupation": occupationStr
        })
    }

    return result
}

求人詳細ページの作成

親ページ作成

import {Metadata} from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import JobDetail from "@/components/JobDetail/JobDetail";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | 求人詳細",
};

export default function JobDetailPage({searchParams}: {
    searchParams: { id: string };
}) {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="求人詳細"/>
                <JobDetail jobid={searchParams.id}></JobDetail>
            </DefaultLayout>
        </>
    );
}

求人詳細のコンポーネント作成

求人詳細の実際のデータ取得・表示・更新を行うためのコンポーネントです。

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

/**
 * 求人編集ページ
 */
type Props = {
    jobid: string | null;
};
export default function JobDetail({jobid}: Props) {
    const [message, setMessage] = useState("");
    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 [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>("");
    const [skills, setSkills] = useState<string | null>(null);
    const [occupation_id, setOccupation_id] = useState<string>("");
    const [industry_id, setIndustry_id] = 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(() => {
        getDayoffData();
        getWorklocationData();
        getOccupation();
        getIndustry();
        getJobDataFromId();
    }, [jobid]);

    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);
        setWork_location(tmp_data.work_location);
        setWork_location_detail(tmp_data.work_location_detail);
        setWorking_hours(tmp_data.working_hours);
        setDay_off(tmp_data.day_off);
        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);
        setOccupation_id(tmp_data.occupation_id);
        setIndustry_id(tmp_data.industry_id);
        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 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 () => {
        const timeStamp = new Date().toISOString();

        const dataForUpdate = {
            name: name,
            description: description,
            work_location: work_location,
            work_location_detail: work_location_detail,
            working_hours: working_hours,
            day_off: day_off,
            day_off_detail: day_off_detail,
            employment_class: employment_class,
            annual_income: annual_income,
            annual_income_detail: annual_income_detail,
            treatment: treatment,
            employee_benefits: employee_benefits,
            qualification: qualification,
            required_skills: required_skills,
            skills: skills,
            occupation_id: occupation_id,
            industry_id: industry_id,
            job_image_url_1: job_image_url_1,
            job_image_url_2: job_image_url_2,
            job_image_url_3: job_image_url_3,
            job_image_url_4: job_image_url_4,
            job_image_url_5: job_image_url_5,
            updated_at: timeStamp,
        };

        const {error} = await supabase.from("trn_job").update(dataForUpdate).eq("id", jobid)
        console.log(error);

        if (error) {
            setMessage("保存に失敗しました。");
            return;
        } else {
            setMessage("");
        }
    };

    return (
        <div className="bg-white flex-1 flex flex-col w-full px-4 py-8 justify-center gap-2 animate-in">
            <h1>求人の編集</h1>
            <form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground">
                <DefaultInput name={"name"} value={name} setter={setName} isRequired={true}
                              labelText={"求人名*"} placeholderText={""}
                              inputType={InputType.text}/>

                <DefaultTextarea name={"description"} value={description} setter={setDescription} isRequired={false}
                                 labelText={"求人説明"} placeholderText={""} rowSize={10}/>

                <DefaultSelect name={"work_location"} value={work_location}
                               setter={setWork_location} isRequired={true} labelText={"勤務地*"}
                               selectData={work_locationData} databaseType={DatabaseType.mst_work_location}/>

                <DefaultTextarea name={"work_location_detail"} value={work_location_detail ? work_location_detail : ""}
                                 setter={setWork_location_detail} isRequired={false}
                                 labelText={"勤務地詳細"} placeholderText={""} rowSize={5}/>

                <DefaultInput name={"working_hours"} value={working_hours} setter={setWorking_hours} isRequired={true}
                              labelText={"勤務時間*"} placeholderText={""}
                              inputType={InputType.text}/>

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

                <DefaultTextarea name={"day_off_detail"} value={day_off_detail ? day_off_detail : ""}
                                 setter={setDay_off_detail} isRequired={false}
                                 labelText={"休日詳細"} placeholderText={""} rowSize={10}/>

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

                <DefaultInput name={"annual_income"} value={annual_income} setter={setAnnual_income} isRequired={true}
                              labelText={"想定年収*"} placeholderText={""}
                              inputType={InputType.number}/>

                <DefaultTextarea name={"annual_income_detail"} value={annual_income_detail ? annual_income_detail : ""}
                                 setter={setAnnual_income_detail} isRequired={false}
                                 labelText={"想定年収詳細"} placeholderText={""} rowSize={5}/>

                <DefaultTextarea name={"treatment"} value={treatment ? treatment : ""}
                                 setter={setTreatment} isRequired={false}
                                 labelText={"待遇"} placeholderText={""} rowSize={5}/>

                <DefaultTextarea name={"employee_benefits"} value={employee_benefits ? employee_benefits : ""}
                                 setter={setEmployee_benefits} isRequired={false}
                                 labelText={"福利厚生"} placeholderText={""} rowSize={5}/>

                <DefaultTextarea name={"qualification"} value={qualification}
                                 setter={setQualification} isRequired={true}
                                 labelText={"応募資格*"} placeholderText={""} rowSize={5}/>

                <DefaultTextarea name={"required_skills"} value={required_skills}
                                 setter={setRequired_skills} isRequired={true}
                                 labelText={"必須スキル*"} placeholderText={""} rowSize={5}/>

                <DefaultTextarea name={"skills"} value={skills ? skills : ""}
                                 setter={setSkills} isRequired={false}
                                 labelText={"歓迎スキル"} placeholderText={""} rowSize={5}/>

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

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

                {/*<FileUploadInput name={"job_image_url_1"} value={job_image_url_1 ? job_image_url_1 : ""}*/}
                {/*                 isRequired={false}*/}
                {/*                 labelText={"募集画像1のURL"} placeholderText={""}*/}
                {/*                 inputType={InputType.text} isImage={true} fileSetter={setJob_image_url_1}/>*/}

                {/*<FileUploadInput name={"job_image_url_2"} value={job_image_url_2 ? job_image_url_2 : ""}*/}
                {/*                 isRequired={false}*/}
                {/*                 labelText={"募集画像2のURL"} placeholderText={""}*/}
                {/*                 inputType={InputType.text} isImage={true} fileSetter={setJob_image_url_2}/>*/}


                {/*<FileUploadInput name={"job_image_url_3"} value={job_image_url_3 ? job_image_url_3 : ""}*/}
                {/*                 isRequired={false}*/}
                {/*                 labelText={"募集画像3のURL"} placeholderText={""}*/}
                {/*                 inputType={InputType.text} isImage={true} fileSetter={setJob_image_url_3}/>*/}

                {/*<FileUploadInput name={"job_image_url_4"} value={job_image_url_4 ? job_image_url_4 : ""}*/}
                {/*                 isRequired={false}*/}
                {/*                 labelText={"募集画像4のURL"} placeholderText={""}*/}
                {/*                 inputType={InputType.text} isImage={true} fileSetter={setJob_image_url_4}/>*/}

                {/*<FileUploadInput name={"job_image_url_5"} value={job_image_url_5 ? job_image_url_5 : ""}*/}
                {/*                 isRequired={false}*/}
                {/*                 labelText={"募集画像5のURL"} placeholderText={""}*/}
                {/*                 inputType={InputType.text} isImage={true} fileSetter={setJob_image_url_5}/>*/}

                <SubmitButton
                    formAction={onSubmit}
                    className="bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white mb-2"
                    pendingText="案件情報更新中..."
                >
                    保存
                </SubmitButton>
                {message !== "" && (
                    <p className="mt-4 p-4 bg-foreground/10 text-foreground text-center">
                        {message}
                    </p>
                )}
            </form>
        </div>
    )
        ;
}

求職者一覧ページの作成


親ページの作成

求職者一覧の親ページを作成します。

import { Metadata } from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import CompanyList from "@/components/CompanyList/CompanyList";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import JobSeekerList from "@/components/JobSeekerList/JobSeekerList";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | 求職者一覧",
};

export default function JobSeekerListPage() {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="求職者一覧"/>
                <JobSeekerList />
            </DefaultLayout>
        </>
    );
}

求職者一覧のコンポーネント作成

求職者一覧のデータ取得と子コンポーネントにデータを受け渡すコンポーネントを作成します。

"use client"

import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import {Database} from "@/types/supabase";
import {getFixedJobSeekerData} from "@/components/JobSeekerList/utils/JobSeekerDataUtil";
import JobSeekerListTable from "@/components/JobSeekerList/JobSeekerListTable";

export default function JobSeekerList() {
    const supabase = createClient()
    const [jobSeekerData, setJobSeekerData] = useState<any[]>([])

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

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

        const tmpData = data as Database["public"]["Tables"]["mst_job_seeker"]["Row"][]
        const result = await getFixedJobSeekerData(tmpData)

        setJobSeekerData(result);

    }

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

    return (
        <div>
            <JobSeekerListTable jobSeekerListData={jobSeekerData}></JobSeekerListTable>
        </div>
    )
}

求職者一覧の表示用テーブル作成

JobSeekerList.tsxから受け渡されたデータを表示するテーブルのコンポーネントを作成します。

"use client"
import {useRouter} from "next/navigation";

type Props = {
    jobSeekerListData: any[]
}

const JobSeekerListTable = ({jobSeekerListData}: Props) => {
    const router = useRouter()
    const seeJobSeekerDetail = async (id: string) => {
        router.replace("/jobseekerdetail?id=" + id)
    }
    return (
        <div className="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
            <div className="max-w-full overflow-x-auto">
                <table className="w-full table-auto">
                    <thead>
                    <tr className="bg-gray-2 text-left dark:bg-meta-4">
                        <th className="min-w-[220px] px-4 py-4 font-medium text-black dark:text-white xl:pl-11">
                            ユーザID
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            性別
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            年齢
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            国籍
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            在留資格期限
                        </th>
                    </tr>
                    </thead>
                    <tbody>
                    {jobSeekerListData.map((item, index) => (
                        <tr key={index} onClick={() => seeJobSeekerDetail(item.user_uid)}>
                            <td className="border-b border-[#eee] px-4 py-5 pl-9 dark:border-strokedark xl:pl-11">
                                <h5 className="font-medium text-black dark:text-white">
                                    {item.user_uid}
                                </h5>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.gender}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.age}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.nationality}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.residence_qualification_expired}
                                </p>
                            </td>
                        </tr>
                    ))}
                    </tbody>
                </table>
            </div>
        </div>
    );
};

export default JobSeekerListTable;

求職者一覧のutilファイル作成

求職者一覧のデータを整形して渡すためのutilファイルを作成します。

"use client"

import {Database} from "@/types/supabase";
import {createClient} from "@/utils/supabase/client";
import {getYearsOld} from "./jobseekerUtils";

export async function getFixedJobSeekerData(data: Database["public"]["Tables"]["mst_job_seeker"]["Row"][]) {
    const supabase = createClient();
    const getNationalityData = async () => {
        const { data, error } = await supabase.from("mst_nationality").select();
        if (error) {
            console.log(error);
            return [];
        }

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

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

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

    const result: any[] = []
    const nationalityData = await getNationalityData();
    const workLocationData = await getWorkLocationData();

    for (let i = 0; i < data.length; i++) {
        const age = getYearsOld(data[i]["birthday"])

        let nationalityStr = ""
        for (let j = 0; j < nationalityData.length; j++) {
            const nationalityId = data[i]["nationality_id"]
            if (nationalityId != null) {
                if (data[i]["nationality_id"]! === nationalityData[j]["id"]) {
                    nationalityStr = nationalityData[j]["nationality"]
                }
            }
        }

        let workLocationStr = ""
        for (let j = 0; j < workLocationData.length; j++) {
            const workLocationId = data[i]["desired_work_location"]
            if (workLocationId != null) {
                if (data[i]["desired_work_location"]! === workLocationData[j]["id"]) {
                    workLocationStr = workLocationData[j]["work_location"]
                }
            }
        }

        result.push({
            "user_uid": data[i]["user_uid"],
            "gender": data[i]["gender"],
            "age": age,
            "nationality": nationalityStr,
            "desired_annual_income": data[i]["desired_annual_income"],
            "desired_change_job_date": data[i]["desired_change_job_date"],
            "residence_qualification_expired": data[i]["residence qualification_expired"],
            "workLocation": workLocationStr
        })
    }

    return result
}

生年月日を年齢に変換する関数作成

求職者の情報には生年月日のデータが保存されており、年齢に変換する必要があるため、
こちらのutilファイルを利用します。

export const getYearsOld = (birthday: string) => {
    const dateArr = birthday.split("-");
    const today = new Date();
    let age = today.getFullYear() - parseInt(dateArr[0]);
    const thisYearsBirthday = new Date(today.getFullYear(), parseInt(dateArr[1]) - 1, parseInt(dateArr[2]))

    if(today < thisYearsBirthday){
        //誕生日の調整
        age--;
    }
    return age;
}

求職者詳細のページ作成


親ページの作成

import {Metadata} from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import JobDetail from "@/components/JobDetail/JobDetail";
import JobSeekerDetail from "@/components/JobSeekerDetail/JobSeekerDetail";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | 求職者詳細",
};

export default function JobDetailPage({searchParams}: {
    searchParams: { id: string };
}) {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="求職者詳細"/>
                <JobSeekerDetail jobseekerID={searchParams.id}></JobSeekerDetail>
            </DefaultLayout>
        </>
    );
}

求職者詳細のコンポーネント作成

求職者情報の取得・表示・更新を行うコンポーネントを作成します。

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

type Props = {
    jobseekerID: string,
}
export default function JobSeekerDetail({jobseekerID}: Props) {
    const [message, setMessage] = useState("");
    const supabase = createClient();

    const [nationalityData, setNationalityData] = useState<
        Database["public"]["Tables"]["mst_nationality"]["Row"][]
    >([]);
    const [occupationData, setOccupationData] = useState<
        Database["public"]["Tables"]["mst_occupation"]["Row"][]
    >([]);
    const [residenceQualificationData, setResidenceQualificationData] = useState<
        Database["public"]["Tables"]["mst_residence_qualification"]["Row"][]
    >([]);
    const [workLocationData, setWorkLocationData] = useState<
        Database["public"]["Tables"]["mst_work_location"]["Row"][]
    >([]);

    // フォームデータを保持する
    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 [nationality_id, setNationality_id] = useState<string>("");
    const [current_annual_income, setCurrent_annual_income] = useState<
        number | null
    >(null);
    const [desired_annual_income, setDesired_annual_income] = useState<
        number
    >(0);
    const [spouse, setSpouse] = useState<string | null>(null);
    const [desired_occupation_id_1, setDesired_occupation_id_1] = useState<
        string | null
    >(null);
    const [desired_occupation_id_2, setDesired_occupation_id_2] = useState<
        string | null
    >(null);
    const [desired_occupation_id_3, setDesired_occupation_id_3] = useState<
        string | null
    >(null);
    const [desired_change_job_date, setDesired_change_job_date] = useState<
        string
    >("");
    const [residence_qualification_id, setResidence_qualification_id] = useState<
        string | null
    >(null);
    const [residence_qualification_expired, setResidence_qualification_expired] =
        useState<string>("");
    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);
    const [desired_work_location, setDesired_work_location] = useState<string>("");

    useEffect(() => {
        getNationalityData();
        getOccupationData();
        getResidenceQualificationData();
        getWorklocationData();
        setData();
    }, [jobseekerID]);

    const setData = async () => {

        const {data} = await supabase
            .from("mst_job_seeker")
            .select()
            .eq("user_uid", jobseekerID);

        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);
        }
        setNationality_id(job_seeker_data.nationality_id);

        if (job_seeker_data.current_annual_income != null) {
            setCurrent_annual_income(job_seeker_data.current_annual_income);
        }
        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) {
            setDesired_occupation_id_1(job_seeker_data.desired_occupation_id_1);
        }
        if (job_seeker_data.desired_occupation_id_2 != null) {
            setDesired_occupation_id_2(job_seeker_data.desired_occupation_id_2);
        }
        if (job_seeker_data.desired_occupation_id_3 != null) {
            setDesired_occupation_id_3(job_seeker_data.desired_occupation_id_3);
        }
        setDesired_change_job_date(job_seeker_data.desired_change_job_date);

        if (job_seeker_data.residence_qualification_id != null) {
            setResidence_qualification_id(
                job_seeker_data.residence_qualification_id
            );
        }
        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);
        }
        setDesired_work_location(job_seeker_data.desired_work_location);
    };

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

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

    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;
        setWorkLocationData(fixed_data);
    };

    const getOccupationData = 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;
        setOccupationData(fixed_data);
    };

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

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

    const onSubmit = async () => {
        const timeStamp = new Date().toISOString();

        const {error} = await supabase.from("mst_job_seeker").update({
            last_name: lastname,
            first_name: firstname,
            middle_name: middlename,
            gender: parseInt(gender),
            birthday: birthday,
            zipcode: zipcode,
            address1: address1,
            address2: address2,
            phone: phone,
            nationality_id: nationality_id,
            current_annual_income: current_annual_income,
            desired_annual_income: desired_annual_income,
            spouse: spouse,
            desired_occupation_id_1: desired_occupation_id_1,
            desired_occupation_id_2: desired_occupation_id_2,
            desired_occupation_id_3: desired_occupation_id_3,
            desired_change_job_date: desired_change_job_date,
            residence_qualification_id: residence_qualification_id,
            "residence qualification_expired": residence_qualification_expired,
            "residence qualification_front_image_url":
            residence_qualification_front_image_url,
            "residence qualification_back_image_url":
            residence_qualification_back_image_url,
            profile_image_url: profile_image_url,
            resume_file_url: resume_file_url,
            resume_file_name: resume_file_name,
            desired_work_location: desired_work_location,
            updated_at: timeStamp,
        }).eq("user_uid", jobseekerID)
        console.log(error);

        if (error) {

            if (error.message === "duplicate key value violates unique constraint \"mst_job_seeker_phone_key\"") {
                setMessage("保存に失敗しました。ご利用の電話番号は既に登録されています。")
                return;
            }

            setMessage("保存に失敗しました。");
            return;
        } else {
            setMessage("");
        }
    };

    return (
        <div className="bg-white flex-1 flex flex-col px-4 py-8 justify-center gap-2">
            <form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground">
                <h2>求職者の登録情報</h2>
                <>
                    <DefaultInput name={"lastname"} value={lastname} setter={setLastname} isRequired={true}
                                  labelText={"姓*"} placeholderText={"山田"}
                                  inputType={InputType.text}/>
                    <DefaultInput name={"firstname"} value={firstname} setter={setFirstname} isRequired={true}
                                  labelText={"名*"} placeholderText={"太郎"}
                                  inputType={InputType.text}/>
                    <DefaultInput name={"middlename"} value={middlename != null ? middlename : undefined}
                                  setter={setMiddlename} isRequired={false}
                                  labelText={"ミドルネーム"} placeholderText={"ミドルネーム"}
                                  inputType={InputType.text}/>
                    <DefaultSelect name={"gender"} value={gender}
                                   setter={setGender} isRequired={true} labelText={"性別*"}
                                   selectData={[]} databaseType={DatabaseType.mst_gender}/>
                    <DefaultInput name={"birthday"} value={birthday} setter={setBirthday} isRequired={true}
                                  labelText={"誕生日*"} placeholderText={""}
                                  inputType={InputType.date}/>
                    <DefaultInput name={"zipcode"} value={zipcode != null ? zipcode : undefined}
                                  setter={setZipcode} isRequired={false}
                                  labelText={"郵便番号"} placeholderText={"0000000 ※ハイフンなしでご入力ください"}
                                  inputType={InputType.text}/>

                    <DefaultInput name={"address1"} value={address1 != null ? address1 : undefined}
                                  setter={setAddress1} isRequired={false}
                                  labelText={"住所1"} placeholderText={"東京都港区浜松町2丁目2番15号"}
                                  inputType={InputType.text}/>

                    <DefaultInput name={"address2"} value={address2 != null ? address2 : undefined}
                                  setter={setAddress2} isRequired={false}
                                  labelText={"住所2"} placeholderText={"浜松町ダイヤビル2F"}
                                  inputType={InputType.text}/>

                    <DefaultInput name={"phone"} value={phone != null ? phone : undefined}
                                  setter={setPhone} isRequired={false}
                                  labelText={"電話番号"}
                                  placeholderText={"00000000000 ※ハイフンなしでご入力ください"}
                                  inputType={InputType.text}/>

                    <DefaultSelect name={"nationalityId"} value={nationality_id}
                                   setter={setNationality_id} isRequired={true} labelText={"国籍*"}
                                   selectData={nationalityData} databaseType={DatabaseType.mst_nationality}/>

                    <DefaultInput name={"current_annual_income"}
                                  value={current_annual_income != null ? current_annual_income : undefined}
                                  setter={setCurrent_annual_income} isRequired={false}
                                  labelText={"現在の年収"} placeholderText={"現在の年収"}
                                  inputType={InputType.number}/>

                    <DefaultInput name={"desired_annual_income"}
                                  value={desired_annual_income}
                                  setter={setDesired_annual_income} isRequired={true}
                                  labelText={"希望年収*"} placeholderText={"希望年収*"}
                                  inputType={InputType.number}/>

                    <DefaultSelect name={"spouse"} value={spouse ? spouse : ""}
                                   setter={setSpouse} isRequired={false} labelText={"配偶者"}
                                   selectData={[]} databaseType={DatabaseType.mst_spouse}/>

                    <DefaultSelect name={"desired_occupation_id_1"}
                                   value={desired_occupation_id_1 ? desired_occupation_id_1 : ""}
                                   setter={setDesired_occupation_id_1} isRequired={false} labelText={"希望職種1"}
                                   selectData={occupationData} databaseType={DatabaseType.mst_occupation}/>

                    <DefaultSelect name={"desired_occupation_id_2"}
                                   value={desired_occupation_id_2 ? desired_occupation_id_2 : ""}
                                   setter={setDesired_occupation_id_2} isRequired={false} labelText={"希望職種2"}
                                   selectData={occupationData} databaseType={DatabaseType.mst_occupation}/>

                    <DefaultSelect name={"desired_occupation_id_3"}
                                   value={desired_occupation_id_3 ? desired_occupation_id_3 : ""}
                                   setter={setDesired_occupation_id_3} isRequired={false} labelText={"希望職種3"}
                                   selectData={occupationData} databaseType={DatabaseType.mst_occupation}/>

                    <DefaultInput name={"desired_change_job_date"}
                                  value={desired_change_job_date}
                                  setter={setDesired_change_job_date} isRequired={true}
                                  labelText={"転職希望日*"} placeholderText={""}
                                  inputType={InputType.date}/>

                    <DefaultSelect name={"residence_qualification_id"}
                                   value={residence_qualification_id ? residence_qualification_id : ""}
                                   setter={setResidence_qualification_id} isRequired={false} labelText={"在留資格"}
                                   selectData={residenceQualificationData}
                                   databaseType={DatabaseType.mst_residence_qualification}/>

                    <DefaultInput name={"residence_qualification_expired"}
                                  value={residence_qualification_expired}
                                  setter={setResidence_qualification_expired} isRequired={true}
                                  labelText={"在留資格期限*"} placeholderText={""}
                                  inputType={InputType.date}/>

                    <DefaultSelect name={"desired_work_location"}
                                   value={desired_work_location}
                                   setter={setDesired_work_location} isRequired={true} labelText={"希望勤務地*"}
                                   selectData={workLocationData} databaseType={DatabaseType.mst_work_location}/>

                    {/*<FileUploadInput name={"residence_qualification_front_image_url"}*/}
                    {/*                 value={residence_qualification_front_image_url != null ? residence_qualification_front_image_url : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"在留資格カード表のファイルURL"}*/}
                    {/*                 placeholderText={"在留資格カード表のファイルURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setResidence_qualification_front_image_url}/>*/}

                    {/*<FileUploadInput name={"residence_qualification_back_image_url"}*/}
                    {/*                 value={residence_qualification_back_image_url != null ? residence_qualification_back_image_url : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"在留資格カード裏のファイルURL"}*/}
                    {/*                 placeholderText={"在留資格カード裏のファイルURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setResidence_qualification_back_image_url}/>*/}

                    {/*<FileUploadInput name={"profile_image_url"}*/}
                    {/*                 value={profile_image_url != null ? profile_image_url : undefined}*/}
                    {/*                 isRequired={false}*/}
                    {/*                 labelText={"プロフィール画像のファイルURL"}*/}
                    {/*                 placeholderText={"プロフィール画像のファイルURL"}*/}
                    {/*                 inputType={InputType.text} isImage={true} fileSetter={setProfile_image_url}/>*/}

                    <FileUploadInput name={"resume_file_url"}
                                     value={resume_file_url != null ? resume_file_url : undefined}
                                     isRequired={false}
                                     labelText={"履歴書のファイルURL"}
                                     placeholderText={"履歴書のファイルURL"}
                                     inputType={InputType.text} isImage={false} fileSetter={setResume_file_url}/>

                    <DefaultInput name={"resume_file_name"}
                                  value={resume_file_name != null ? resume_file_name : undefined}
                                  setter={setResume_file_name} isRequired={false}
                                  labelText={"履歴書のファイル名"}
                                  placeholderText={"履歴書のファイル名"}
                                  inputType={InputType.text}/>
                </>

                <SubmitButton
                    formAction={onSubmit}
                    className="bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white mb-2"
                    pendingText="ユーザ情報更新中..."
                >
                    保存
                </SubmitButton>
                {message !== "" && (
                    <p className="mt-4 p-4 bg-foreground/10 text-foreground text-center">
                        {message}
                    </p>
                )}
            </form>
        </div>
    );
}

チャット一覧ページの作成


親ページの作成

import { Metadata } from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import ChatList from "@/components/ChatList/ChatList";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | チャット一覧",
};

export default function ChatListPage() {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="チャット一覧"/>
                <ChatList></ChatList>
            </DefaultLayout>
        </>
    );
}

チャット一覧のコンポーネント作成

チャット一覧のデータ取得・子コンポーネントへの受け渡しを行うコンポーネントです。

"use client"

import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import {Database} from "@/types/supabase";
import ChatListTable from "@/components/ChatList/ChatListTable";

export default function ChatList() {
    const supabase = createClient()
    const [chatListData, setChatListData] = useState<any[]>([])

    const getChatListData = async () => {
        const {data, error} = await supabase.from("m2m_job_seeker_job").select()

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

        const tmpData = data as Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][]

        setChatListData(tmpData)
    }

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

    return (
        <div>
            <ChatListTable chatlistData={chatListData}/>
        </div>
    )
}

チャット一覧表示用のテーブル作成

ChatList.tsxから受け渡されたデータを表示するコンポーネントです。

"use client"

import {useRouter} from "next/navigation";
import {Database} from "@/types/supabase";
import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";

type Props = {
    chatlistData: Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][]
}

const ChatListTable = ({chatlistData}: Props) => {
    const supabase = createClient()
    const router = useRouter()
    const [fixedChatListData, setFixedChatListData] = useState<any[]>([])

    const getChatListData = async () => {
        const jobData = await getJobData()
        const jobSeekerData = await getJobSeekerData()

        let result: any[] = []
        for (let i = 0; i < chatlistData.length; i++) {
            let jobName = ""
            for (let j = 0; j < jobData.length; j++) {
                if (chatlistData[i].job_id === jobData[j].id) {
                    jobName = jobData[j].name;
                }
            }

            let jobSeekerName = ""
            for (let j = 0; j < jobSeekerData.length; j++) {
                if (chatlistData[i].job_seeker_id === jobSeekerData[j].user_uid) {
                    jobSeekerName = jobSeekerData[j].last_name + " " + jobSeekerData[j].first_name;
                }
            }

            let statusStr = ""

            switch (chatlistData[i].status) {
                case "selection_resume":
                    statusStr = "書類選考中"
                    break;
                case "scheduling_inteview":
                    statusStr = "面接日程調整中"
                    break;
                case "waiting_inteview_result":
                    statusStr = "面接結果待ち"
                    break;
                case "reject":
                    statusStr = "不採用"
                    break;
                case "offer":
                    statusStr = "オファー"
                    break
                case "decline_before_offer":
                    statusStr = "内定前辞退"
                    break
                case "decline_after_offer":
                    statusStr = "内定後辞退"
                    break
                case "accept_offer":
                    statusStr = "内定承諾"
                    break
            }

            result.push({
                id: chatlistData[i].id,
                jobname: jobName,
                username: jobSeekerName,
                status: statusStr,
            })
        }

        setFixedChatListData(result)
    }

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

        if (error) {
            return []
        }

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

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

        if (error) {
            return []
        }

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

    useEffect(() => {
        getChatListData()
    }, [chatlistData])

    const seeCompanyDetail = async (id: string) => {
        router.replace("/chatlistdetail?id=" + id)
    }
    return (
        <div className="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
            <div className="max-w-full overflow-x-auto">
                <table className="w-full table-auto">
                    <thead>
                    <tr className="bg-gray-2 text-left dark:bg-meta-4">
                        <th className="min-w-[220px] px-4 py-4 font-medium text-black dark:text-white xl:pl-11">
                            求人名
                        </th>
                        <th className="min-w-[150px] px-4 py-4 font-medium text-black dark:text-white">
                            求職者名
                        </th>
                        <th className="min-w-[120px] px-4 py-4 font-medium text-black dark:text-white">
                            ステータス
                        </th>
                    </tr>
                    </thead>
                    <tbody>
                    {fixedChatListData.map((item, index) => (
                        <tr key={index} onClick={() => seeCompanyDetail(item.id)}>
                            <td className="border-b border-[#eee] px-4 py-5 pl-9 dark:border-strokedark xl:pl-11">
                                <h5 className="font-medium text-black dark:text-white">
                                    {item.jobname}
                                </h5>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.username}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.status}
                                </p>
                            </td>
                        </tr>
                    ))}
                    </tbody>
                </table>
            </div>
        </div>
    );
};

export default ChatListTable;

チャット詳細ページの作成

親ページの作成

import {Metadata} from "next";
import DefaultLayout from "@/components/Layouts/DefaultLayout";
import CompanyDetail from "@/components/CompanyDetail/CompanyDetail";
import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb";
import ChatListDetail from "@/components/ChatListDetail/ChatListDetail";

export const metadata: Metadata = {
    title:
        "求人アプリ管理画面 | チャット詳細",
};

export default function ChatListDetailPage({searchParams}: {
    searchParams: { id: string };
}) {
    return (
        <>
            <DefaultLayout>
                <Breadcrumb pageName="チャット詳細"/>
                <ChatListDetail chatlistID={searchParams.id}/>
            </DefaultLayout>
        </>
    );
}

チャット詳細のコンポーネント作成

こちらはレイアウトを作成するのみのコンポーネントです。

"use client"

import ChatListDetailTable from "@/components/ChatListDetail/ChatListDetailTable";

type Props = {
    chatlistID: string,
}
export default function ChatListDetail({chatlistID}: Props) {
    return (
        <div>
            <ChatListDetailTable chatlistID={chatlistID}/>
        </div>
    );
}

チャット詳細の表示用テーブル作成

同じルームIDのチャット一覧を取得・表示・削除ができるコンポーネントです。

"use client"

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

type Props = {
    chatlistID: string
}

const ChatListDetailTable = ({chatlistID}: Props) => {
    const supabase = createClient()
    const [chatMessagelistData, setChatMessagelistData] = useState<Database["public"]["Tables"]["trn_apply_message"]["Row"][]>([])

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

    const getChatMessages = async () => {
        const {data, error} = await supabase.from("trn_apply_message").select()
            .eq("job_seeker_job_id", chatlistID)

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

        setChatMessagelistData(data)
    }

    const updateDeleteFlg = async (id: number) => {
        if(window.confirm("このメッセージを削除しますか?")) {
            const {error} = await supabase.from("trn_apply_message").update({
                delete_flg: true
            }).eq("id", id)

            await getChatMessages()
        }
    }

    const restoreMessage = async (id: number) => {
        if(window.confirm("このメッセージを復活しますか?")) {
            const {error} = await supabase.from("trn_apply_message").update({
                delete_flg: false
            }).eq("id", id)

            await getChatMessages()
        }
    }

    return (
        <div className="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
            <div className="max-w-full overflow-x-auto">
                <table className="w-full table-auto">
                    <thead>
                    <tr className="bg-gray-2 text-left dark:bg-meta-4">
                        <th className="min-w-[220px] px-4 py-4 font-medium text-black dark:text-white xl:pl-11">
                            メッセージ
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            送信者ID
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            受信者ID
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            ステータス
                        </th>
                        <th className="px-4 py-4 font-medium text-black dark:text-white">
                            アクション
                        </th>
                    </tr>
                    </thead>
                    <tbody>
                    {chatMessagelistData.map((item, index) => (
                        <tr key={index}>
                            <td className="border-b border-[#eee] px-4 py-5 pl-9 dark:border-strokedark xl:pl-11">
                                <h5 className="font-medium text-black dark:text-white">
                                    {item.message}
                                </h5>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.sender_id}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.receiver_id}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                <p className="text-black dark:text-white">
                                    {item.delete_flg ? ("削除済み") : ""}
                                </p>
                            </td>
                            <td className="border-b border-[#eee] px-4 py-5 dark:border-strokedark">
                                {item.delete_flg ? (
                                    <button type="button" onClick={() => restoreMessage(item.id)}>
                                        <span className="material-symbols-outlined">history</span>
                                    </button>
                                    ) : (
                                    <button type="button" onClick={() => updateDeleteFlg(item.id)}>
                                        <span className="material-symbols-outlined">delete</span>
                                    </button>
                                )}

                            </td>
                        </tr>
                    ))}
                    </tbody>
                </table>
            </div>
        </div>
    );
};

export default ChatListDetailTable;

ここまでで管理画面の作成は完了です!
実際に利用して各機能を確認してみてください。

その他参考資料など

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

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

お問合せ&各種リンク

presented by


サポートしていただくと、筆者のやる気がガンガンアップします!