LEMP環境でNext.js(TypeScript)とLaravel(PHP)を利用して、勤怠管理アプリを作成してみる。〜最終工程編〜

前回は、退会ページの作成をし、勤怠管理アプリのすべてのページのコンポーネントを作成しました。

ここまでは、ただコンポーネントを作成しただけです。

次は、この作成したコンポーネントを機能させるためのファイルを作成します。

その前に、グローバルナビゲーションは、ユーザー情報があるかないかで表示される内容が異なるので、

ユーザー情報を渡すための関数をfrontendディレクトリ内のsrcディレクトリ内にhooksというディレクトリを作成し、

useAuth.tsというファイルを作成します。

```typescript:frontend/src/hooks/useAuth.ts
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { useMutation } from 'react-query';
import { Me } from '../types';

// APIを呼び出すための関数
const loginUser = async ({ userId, password }: { userId: string; password: string }) => {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/login`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({ userId, password }), // JSON形式で送信
    });

    if (!response.ok) {
        throw new Error('ログインに失敗しました。');
    }

    return response.json();
};

const useAuth = (): {
    user: Me | null;
    loading: boolean;
    errorMessage: string | null;
    login: (userId: string, password: string) => void;
} => {
    const [user, setUser] = useState<Me | null>(null); // ユーザー情報を保持
    const [errorMessage, setErrorMessage] = useState<string | null>(null); // エラーメッセージの保持
    const router = useRouter();

    const mutation = useMutation(loginUser, {
        onMutate: () => {
            setErrorMessage(null);
        },
        onSuccess: (data) => {
            setUser(data.user); // 成功時ユーザー情報をセット
        },
        onError: (error: any) => {
            setErrorMessage(error.message || 'ログインに失敗しました。'); // エラーメッセージを表示
        },
    });

    // ログインリクエストを外部から呼び出す関数
    const login = (userId: string, password: string) => {
        mutation.mutate({ userId, password });
    };

    // ユーザーが null の場合、ログインページへリダイレクト
    useEffect(() => {
        if (user === null && router.pathname !== '/login') {
            router.push('/login');
        }
    }, [user, router]);

    return {
        user,
        loading: mutation.isLoading,
        errorMessage,
        login,
    };
};

export default useAuth;


```

useMutationという
react-query を使用するには、QueryClientProvider を使って QueryClient をコンテキストとしてアプリ全体に渡す必要があります。

QueryClient の作成
```typescript:
const queryClient = new QueryClient(); 
```
で QueryClient をインスタンス化します。

これにより、クエリやミューテーションの管理が可能になります。

## QueryClientProvider.tsx の作成

まず、React Query の QueryClientProvider を 

appディレクトリ内にprovidersディレクトリを作成し、QueryClientProvider.tsxを作成し、そこに定義します。


```tsx:frontend/src/app/providers/QueryClientProvider.tsx
"use client";

import { QueryClient, QueryClientProvider } from "react-query";
import { ReactNode, useState } from "react";

export default function ReactQueryProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}


```

useState を使うことで QueryClient インスタンスがコンポーネントの再レンダリングごとに作成されるのを防ぎます。

もし、childrenにエラーが出る場合、
tsconfig.jsonファイルを
```diff_json:frontend/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
+ },
+ "typeRoots": [
+ "./node_modules/@types",  
+ "./node_modules/react-query"  
+ ],
+ "types": ["react", "react-dom"]  // ここに追加
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

```
してください。

変更後に node_modules を削除して再インストールしてください。

```
$ rm -rf node_modules package-lock.json
$ npm install
```

### layout.tsx に QueryClientProvider を適用

次に、layout.tsxを以下のように変更し、 QueryClientProvider をラップ します。

```tsx:frontend/src/app/layout.tsx
import "../globals.css";
import ReactQueryProvider from "./providers/QueryClientProvider";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "勤怠管理アプリ",
  description: "Next.js & Laravel を使った勤怠管理アプリ",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <ReactQueryProvider>{children}</ReactQueryProvider>
      </body>
    </html>
  );
}

```
### page.tsx で useQuery を使う

page.tsx で useQuery を使うには、クライアントコンポーネント ("use client") にする必要があります。

また、appディレクトリ内にapiディレクトリを作成し、auth.tsファイルを作成します。

そして、API ルートに関するコードを書いていきます。

```ts:frontend/src/app/api/auth.ts
import { NextApiRequest, NextApiResponse } from "next";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === "GET") {
    res.status(200).json({ message: "認証API" });
  } else {
    res.status(405).json({ message: "認証APIエラー" });
  }
}
```
Next.jsを動かした際、ブラウザで、(http://localhost:3000) または、(http://localhost:8080(Dockerを起動させた時)と入力したとき、 エントリーポイントとなるファイルは、frontend/src/app/page.tsxです。

なので、

```tsx:frontend/src/app/page.tsx
import Image from "next/image";

export default function Home() {
  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
        <Image
          className="dark:invert"
          src="/next.svg"
          alt="Next.js logo"
          width={180}
          height={38}
          priority
        />
        <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
          <li className="mb-2">
            Get started by editing{" "}
            <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
              src/app/page.tsx
            </code>
            .
          </li>
          <li>Save and see your changes instantly.</li>
        </ol>

        <div className="flex gap-4 items-center flex-col sm:flex-row">
          <a
            className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
            target="_blank"
            rel="noopener noreferrer"
          >
            <Image
              className="dark:invert"
              src="/vercel.svg"
              alt="Vercel logomark"
              width={20}
              height={20}
            />
            Deploy now
          </a>
          <a
            className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
            target="_blank"
            rel="noopener noreferrer"
          >
            Read our docs
          </a>
        </div>
      </main>
      <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
        <a
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
          href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          <Image
            aria-hidden
            src="/file.svg"
            alt="File icon"
            width={16}
            height={16}
          />
          Learn
        </a>
        <a
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
          href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          <Image
            aria-hidden
            src="/window.svg"
            alt="Window icon"
            width={16}
            height={16}
          />
          Examples
        </a>
        <a
          className="flex items-center gap-2 hover:underline hover:underline-offset-4"
          href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          <Image
            aria-hidden
            src="/globe.svg"
            alt="Globe icon"
            width={16}
            height={16}
          />
          Go to nextjs.org →
        </a>
      </footer>
    </div>
  );
}
```
というコードを変えないといけません。

「▲ Deploy now」という部分だけ残し、書き換えるので、他のボタンや要素は、削除します。


しかし、現在、 ```<main> ```のクラスが``` sm:items-start``` になっているため、sm (小さい画面以上) では左寄せになってしまいます。

なので、 ```sm:items-start ```を削除し ```items-center``` のみにする

```tsx
<main className="flex flex-col gap-8 row-start-2 items-center">
```
### text-center を追加

リスト ```(<ol>) ```の ```text-left ```が原因で、リスト内の文章が左寄せになっています。
```sm:text-left ```を削除します。

```tsx:

<ol className="list-inside list-decimal text-sm text-center font-[family-name:var(--font-geist-mono)]">
```
### ボタンを中央寄せ
そして、ボタンの ```<div>``` に ```sm:flex-row``` があるため、小さい画面では縦並び、大きい画面では横並び になっています。

なので、```sm:flex-row``` を削除し```justify-center ```を追加

```tsx
<div className="flex gap-4 items-center flex-col justify-center">
```

ボタンの色を青緑色にしたいので、Tailwind CSS の teal 系の色 ```(teal-500)``` を使います。

また、ホバー時に濃い青緑にするために ```teal-600``` を設定します。

```bg-foreground text-background``` → ```bg-teal-500 text-white```
```hover:bg-[#383838] dark:hover:bg-[#ccc]``` → ```hover:bg-teal-600```

これらとAPIなどを踏まえると、コードを下のように変えていきます。

```tsx:frontend/src/app/page.tsx
"use client";

import Image from "next/image";
import { useQuery } from "react-query";

async function fetchData() {
  const res = await fetch("/api/auth"); // APIエンドポイントを変更
  if (!res.ok) throw new Error("データの取得に失敗しました");
  return res.json();
}

export default function Home() {
  const { data, error, isLoading } = useQuery("auth", fetchData);

  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <main className="flex flex-col gap-8 row-start-2 items-center">
        <Image
          className="dark:invert"
          src="/next.svg"
          alt="Next.js logo"
          width={180}
          height={38}
          priority
        />
        {isLoading && <p>読み込み中...</p>}
        {error && <p>エラーが発生しました</p>}
        {data && <p>取得データ: {JSON.stringify(data)}</p>}
        <ol className="list-inside list-decimal text-sm text-center font-[family-name:var(--font-geist-mono)]">
          <li className="mb-2">
            自分で勤怠管理アプリ作ってみました。{" "}
            <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
              Next.js and Laravel
            </code>
          </li>
        </ol>

        <div className="flex gap-4 items-center flex-col justify-center">
          <a
            className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-teal-500 text-white gap-2 hover:bg-teal-600 text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
            href="/login"
            target="_blank"
            rel="noopener noreferrer"
          >
            <Image
              className="dark:invert"
              src="/vercel.svg"
              alt="Vercel logomark"
              width={20}
              height={20}
            />
            アプリを始める
          </a>
        </div>
      </main>
    </div>
  );        
}
```

この構成にすれば、全てのページで<font color=blue>**react-query**</font>を統一して使用にします。

その後、以下のようにディレクトリとファイルを作成し、作成したコンポーネントを貼り付けていきます。

```tsx:frontend/src/app/login/page.tsx
import { NextPage } from 'next/types';
import Login from '@/components/pages/Login';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <Login/>;
};

export default Index;

```

```typescript:frontend/src/app/leave/page.tsx

import { NextPage } from 'next/types';
import Leave from '@/components/pages/Leave';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <Leave/>;
};

export default Index;
```

```typescript:frontend/src/app/leave/confirm/page.tsx
import { NextPage } from 'next/types';
import LeaveConfirm from '@/components/pages/Leave/Confirm';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <LeaveConfirm/>;
};

export default Index;
```
```frontend/src/app/leave/complete/page.tsx
import { NextPage } from 'next/types';
import LeaveComplete from '@/components/pages/Leave/Complete';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <LeaveComplete/>;
};

export default Index;

```
```typescript:frontend/src/app/mypage/page.tsx
import { NextPage } from 'next/types';
import Mypage from '@/components/pages/Mypage';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <Mypage/>;
};

export default Index;

```
```typescript:frontend/src/app/register/page.tsx
import { NextPage } from 'next/types';
import Register from '@/components/pages/Register';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <Register/>;
};

export default Index;
```
```typescript:frontend/src/app/report/page.tsx
import { NextPage } from 'next/types';
import Report from '@/components/pages/Report';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <Report onSubmissionFail={() => {} }/>;
};

export default Index;

```
```typescript:frontend/src/app/reset_password/page.tsx
import { NextPage } from 'next/types';
import ResetPassword from '@/components/pages/ResetPassword';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <ResetPassword/>;
};

export default Index;
```
```typescript:frontend/src/app/time_tracking/page.tsx
import { NextPage } from 'next/types';
import TimeTracking from '@/components/pages/TimeTracking';
import useAuth from '@/hooks/useAuth';

const Index: NextPage = () => {
  const { loading, user } = useAuth();
  
  if (loading) {
    return <div>処理中...</div>
  }

  if (!user) {
    return <div>ユーザーが見つかりませんでした。</div>
  }

  return <TimeTracking/>;
};

export default Index;
```

では、作成した勤怠管理アプリを動かしてみます。

### Dockerの起動

```
$ docker-compose up -d –build
```
そして、ブラウザで、http://localhost:3000 または、http://localhost:8080

と入力して動かしてみると、

![新規メモ.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3945016/ac49b36c-344b-0081-e7c2-cb60fb4c1d38.jpeg)


というページが、表示されます。

そして、「▲アプリを始める」というボタンを押すと、ログインページに移動します。

しかし、

![新規メモ.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3945016/20b009a8-8a57-9824-9651-f3822c99595b.jpeg)

というエラーが出ているので、

現在、Next.jsやLaravelとのAPI通信ができるように、コードを変更している最中でございます。

進展があれば、随時、更新されるので、都度、確認の方お願いします。

もし、ここまでの記事で、この部分、こうした方がいいやアドバイスなどがあれば、ありがたいです。