0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LEMP環境でPHPとReact(TypeScript)で、簡単な勤怠管理アプリを作成中です。

Last updated at Posted at 2024-11-30

前回は、LEMP環境で、PHPとReact(TypeScript)を使って、簡単な掲示板アプリを作成しました。

今回は、その簡単な掲示板アプリの作り方を参考に勤怠管理アプリを作成しているところでございます。

以下が、作成しようとしている勤怠管理アプリの機能です。

まず、新規登録・ログインの機能があり、ログインしたユーザーのみがトップページに進める

▼ログインページ
 タイトル : ログイン
 必要カラム:「パスワード」「ユーザー名」
 ボタン :
「ログイン」→ トップ画面
「新規登録はこちら」→新規登録ページ
 「パスワードを忘れた方はこちらへ」 → パスワード再設定画面

▼新規登録ページ

ユーザー名
メールアドレス
パスワード
プロフィールアイコン
生年月日
性別

▼パスワード再設定ページ
 必要カラム: 「登録済みメールアドレス」
ボタン: 「パスワードを再設定」→ 登録したメールアドレスに再設定するためのメールが届く

ログインが成功すると、

▼トップ画面(新たに作る)

〇〇〇〇年/
画面の真ん中に現在の時刻(◯(時):◯(分):◯(秒))という形で表示されるようにする。
下に各ボタンを配置
ボタン ※各ボタンを押すとデータベースに記録されるようする。
 「出勤」→ 「お仕事頑張ってください」というメッセージが出る 
 「退勤」→ 「お仕事お疲れ様でした」というメッセージが出る→ 出勤時間、退勤時間、休憩開始時間、休憩終了時間、勤務時間数、休憩時間数がレコードで表示される。
「休憩開始」→ 「休憩を開始しました。」というメッセージが出る。
「休憩終了」→「休憩を終了しました。」というメッセージが出る。 
「作業日報」→作成した「作業日報」ページへ
タイトル「掲示板」→「作業日報」 に変更
「掲示板一覧」→ 「日報一覧」に変更
「投稿する」ボタンを「提出する」に変更

では、先ほど作成したkeijibanディレクトリをコピーして、kintaiディレクトリに変えます。

まず、

keijibanディレクトリ内で、

$ pwd

とコマンドを打つと、現在いるパスが表示されます。

そして、

$ cp -r  [$pwdで表示されたパス]  [$pwdで表示されたパスの最後の部分(keijiban -> kintai)に変える]

とコマンドを打つと、

kintaiディレクトリができあがります。

今回は、このディレクトリを使用します。

  1. ディレクトリ構造の変更

新しい機能を追加するために、以下のようなディレクトリ構造を考えます

kintai/
├── docker-compose.yml
├── Dockerfile
├── nginx/
│   └── default.conf
├── php/
│   ├── index.php
│   ├── post.php
│   ├── delete.php
│   ├── auth.php            # 新規追加: 認証関連
│   ├── register.php        # 新規追加: ユーザー登録
│   ├── reset_password.php   # 新規追加: パスワード再設定
│   ├── time_tracking.php # 新規追加: 勤怠管理
|   └── leave.php #新規追加: 退会処理
├── mysql/
│   └── init.sql
└── frontend
│   ├── Dockerfile
│   │   ├── src
│   │   │   └──pages
│   │   │   │   ├── leave
│   │   │   │   │   ├── complete.tsx # 新規追加: 退会完了コンポーネントを表示する
│   │   │   │   │   ├── confirm.tsx # 新規追加: 退会確認コンポーネントを表示する
│   │   │   │   │   └── index.tsx # 新規追加: 退会コンポーネントを表示する
│   │   │   │   ├── mypage
│   │   │   │   │   └── index.tsx # 新規追加: マイページコンポーネントを表示する
│   │   │   │   ├── register
│   │   │   │   │   └── index.tsx # 新規追加: 新規登録コンポーネントを表示する
│   │   │   │   ├── report
│   │   │   │   │   └── index.tsx # 新規追加: 日報コンポーネントを表示する
│   │   │   │   ├── reset_password
│   │   │   │   │   └── index.tsx # 新規追加: パスワード再設定コンポーネントを表示する
│   │   │   │   └── time_tracking
│   │   │   │       └── index.tsx # 新規追加: 勤怠管理コンポーネントを表示する
│   │   │   ├── components # 新規追加: コンポーネントを分ける
│   │   │   │      ├── pages
│   │   │   │      │     ├── App
│   │   │   │      │     │   ├── index.css # 変更: ログインコンポーネントのCSS
│   │   │   │      │     │   └── index.tsx # 変更: ログインコンポーネント
│   │   │   │      │     ├── Leave
│   │   │   │      │     │   ├── Complete.tsx # 新規追加: 退会完了コンポーネント
│   │   │   │      │     │   ├── Confirm.tsx # 新規追加: 退会確認コンポーネント
│   │   │   │      │     │   └── index.tsx # 新規追加: 退会コンポーネント
│   │   │   │      │     ├── Mypage
│   │   │   │      │     │   ├── index.css # 新規追加: マイページコンポーネントのCSS
│   │   │   │      │     │   └── index.tsx # 新規追加: マイページコンポーネント
│   │   │   │      │     ├── Register
│   │   │   │      │     │   └── index.tsx  # 新規追加: 新規登録コンポーネント
│   │   │   │      │     ├── Report
│   │   │   │      │     │   ├── index.css # 新規追加: 日報コンポーネントのCSS
│   │   │   │      │     │   └── index.tsx # 新規追加: 日報コンポーネント
│   │   │   │      │     ├── ResetPassword
│   │   │   │      │     │   └── index.tsx  # 新規追加: パスワード再設定コンポーネント
│   │   │   │      │     └── TimeTracking
│   │   │   │      │     ├── index.css  # 新規追加: 勤怠管理コンポーネントのCSS
│   │   │   │      │     └── index.tsx  # 新規追加: 勤怠管理コンポーネント
│   │   │   │      ├── hooks
│   │   │   │      │     └── useAuth.ts
│   │   │   │      └── shared
│   │   │   │            └── Globalnav
│   │   │   │                    └── index.tsx
│   │   │   ├── index.css
│   │   │   ├── index.tsx # エントリーポイントとなるファイル(# 新規追加: ログインコンポーネントを表示する)
│   │   │   └── types
                  └── index.ts# 新規追加: ユーザー情報

keijibanディレクトリをkintaiという名前に変更したので、docker-compose.ymlやmysqlディレクトリ内のinit.sqlの内容も以下のように変更します。

kintai/mysql/init.sql
-- データベースを使用
CREATE DATABASE IF NOT EXISTS kintai;

USE kintai;

-- 作業日報テーブルの作成
CREATE TABLE IF NOT EXISTS posts (
    post_id INT AUTO_INCREMENT PRIMARY KEY,
    author_name VARCHAR(50) NOT NULL,
    title VARCHAR(100) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
kintai/docker-compose.yml
version : '3.8'

services:
  nginx:
    image: nginx:alpine
    ports:
     - "8080:80"
    volumes:
     - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
     - ./php:/var/www/html
    depends_on:
     - php

  php:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./php:/var/www/html
    depends_on:
      - mysql

  mysql:
    image: mysql:5.7
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: kintai
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    volumes:
      - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "3306:3306"

  frontend:
    build:
      context: ./frontend
    ports:
      - "3000:80"

1.ログインページ

frontend/src/components/pages/App/index.tsx
import React, {useState} from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient, useMutation } from 'react-query';


const App: React.FC = () => {
    const [userId, setUserId] = useState('');
    const [password, setPassword] = useState('');
    const [message, setMessage] = useState('');
    const navigate = useNavigate();
    const queryClient = useQueryClient();

    const mutation = useMutation(async ({userId, password}:{userId: string; password: string}) => {
        const response = await fetch('http://localhost:8080/auth.php', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded'},
            body: new URLSearchParams({ userId, password}).toString(),
        });

        if (!response.ok) throw new Error('ログインできませんでした。');
        return await response.json();
    },{
        onSuccess: (data) => {
            if (data.success) {
                // 成功時の処理
                setMessage('ログイン成功');
                //全てのキャッシュを削除
                queryClient.clear();
                
                navigate('/time_tracking');
            } else {
                setMessage(data.message);
            }
        },
        onError: (error: any) => {
            setMessage(error.message || 'エラーが発生しました。')
        }
    });

    const handleLogin = (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate({userId, password});
    };
        
        return (
            <div>
                <h1>ログイン</h1>
                <form onSubmit={handleLogin}>
                    <input 
                    type="text"
                    value={userId}
                    onChange={(e) => setUserId(e.target.value)}
                    placeholder="ID番号"
                    required
                    />
                    <input 
                    type="password"
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    placeholder="パスワード"
                    required/>
                    <div>
                        <small>パスワードは7文字以上8文字以下で英字と数字を両方含む必要があります</small>
                    </div>
                    <button type="submit">ログイン</button>
                </form>
                {message && <div>{message}</div>}
                <div className="register">
                <a href="/register">新規登録はこちら</a>
                </div>
                <div className="password">
                <a href="/reset_password">IDパスワードを忘れた方はこちらへ</a>
                </div>              
            </div>
        );
    };

    export default App;

そして、デザインを整えます。

frontend/src/components/pages/App/index.css
h1 {
  text-align: center;
  margin-top: 50px;
  margin-bottom: 30px;
}

.a {
  text-align: center;
  justify-content: center; /* 横中央揃え */
}

.a:hover {
  color: darkblue;
}

button {
  padding: 10px;
  margin: 0 auto; /* 自動余白で中央揃え */
  border: none;
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 150px;
  height: 70px;
  display: block; /* ボタンをブロック要素に */
  background-color: blue;
  cursor: pointer;
  margin-bottom: 30px;
  border-radius: 4px;
  margin-top: 30px;

}

button:hover {
  background-color: darkblue;
}

input {
  margin:  10px 0; /* 上下に余白を追加 */
  width: 30%;
  padding: 8px;
  border: 1px solid #ccc; /* ボーダーの設定 */
  border-radius:  6px; /* 角を丸める */
}

small {
  font-size: 12px;
  color: gray;
  margin-bottom: 30px; /* 下に30pxの余白を追加 */
}

form {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
}

.register {
  margin-bottom: 20px;
  text-align: center;
}

.register a,
a {
  text-decoration: none; /* 下線を消す */
  color: blue;
  font-size: 14px;
}

.register a:hover,
a:hover {
  color:darkblue;
}

.password {
  text-align: center;
}

すると、下のような画面になると思います。

新規メモ.jpeg

2.新規登録ページ

パスワード入力の制限

今回は、パスワードを半角の英字・数字を両方含む7文字以上8文字以下に設定。

パスワードに対するバリデーションを追加するためには、

バリデーション関数を作成: パスワードが半角の英字・数字を両方含む7文字以上8文字以下であるように設定します。

const validatePassword = (password: string) => { const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/;return passwordRegex.test(password); };

handleRegister関数内でパスワードの条件をチェックし、条件を満たさない場合にはエラーメッセージを表示するようにします

if (!validatePassword(password)) { setMessage('パスワードを7文字以上8文字以下で、英字と数字を両方含むようにしてください。'); return; }

アイコン項目の追加

今回は、登録情報にアイコン項目も追加します。

アイコンを登録するための項目を追加するには、ファイル入力を使って画像をアップロードできるようにします。以下の手順で、コードにアイコンアップロード機能を追加します。

状態管理: アイコンの状態を管理するためのステートを追加します。

ファイル入力: アイコンを選択するためのファイル入力をフォームに追加します。
ファイルアップロード処理: フォーム送信時にアイコンも一緒に送信するようします。

アイコンの状態管理:

const [icon, setIcon] = useState<File | null>(null);

を追加。

ファイル入力の追加:

<input type="file" accept="image/*" onChange={(e) => e.target.files && setIcon(e.target.files[0])} />を追加

FormDataの使用: フォームのデータをFormDataオブジェクトに追加し、アイコンファイルを送信。

これでアイコンを選択して登録できるようになります。サーバー側でもアイコンの処理を適切に行う必要がありますので、その点も考慮してください。

これらを踏まえてのコードは以下になります。

frontend/src/components/pages/Register/index.tsx
import React, {useState} from 'react';
import { useMutation } from 'react-query';
import { Me } from '../../../types';

const Register: React.FC = () => {
    const [username, setUsername] = useState('');
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [birthYear, setBirthYear] = useState('');
    const [birthMonth, setBirthMonth] = useState('');
    const [birthDay, setBirthDay] = useState('');
    const [gender, setGender] = useState('');
    const [icon, setIcon] = useState<File | null>(null);
    const [message, setMessage] = useState('');

    const years = Array.from({ length: 100}, (_, i) => new Date(). getFullYear() - i);
    const months = Array.from({ length: 12}, (_, i) => i + 1);
    const days = Array.from ({length: 31}, (_, i) => i + 1);

    const validatePassword = (password: string) => {
        const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/;
        return passwordRegex.test(password);
    }

    const mutation = useMutation(async () => {
        if (!validatePassword(password)) {
            throw new Error('パスワードを7文字以上8文字以下で、英字と数字を両方含むように設定してください。');
        }

        const formData = new FormData();
        formData.append('username', username);
        formData.append('email', email);
        formData.append('password', password);
        formData.append('birthdate', `${birthYear}${String(birthMonth).padStart(2, '0')}${String(birthDay).padStart(2,'0')}`);
        formData.append('gender', gender);
        if (icon) {
            formData.append('icon', icon);
        }

        const response = await fetch('http://localhost:8080/register.php',{
            method: 'POST',
            body: formData,
        });

        if (!response.ok) throw new Error('登録できませんでした。');
        return await response.json();
    }, {
        onSuccess: (data) => {
            // 登録成功時の処理
            if (data.username && data.iconpath) {
                const user: Me = {
                    user_id: data.user_id, // ここには、PHP側で生成したuser_idを返す必要があります
                    username: data.username,
                    email: email,
                    birthdate: `${birthYear}${String(birthMonth).padStart(2,'0')}${String(birthDay).padStart(2,'0')}`,
                    gender: gender,
                    icon: data.iconpath, // PHPから返されたアイコンのパス
                };

                // localStorageに保存
                localStorage.setItem('user', JSON.stringify(user));
            }
            setMessage(data.message);
        },
        onError: (error: Error) => {
            setMessage(error.message);
        },
    });



    const handleRegister = async (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate();
    };

    return (
        <div>
            <h1>新規登録</h1>
        <form onSubmit={handleRegister}>
            <div className="file-gender-container">
            <input
              type = "file"
              accept="image/*"
              onChange={(e) => e.target.files && setIcon(e.target.files[0])}
              />
            <p>性別 :
            <select
            value={gender}
            onChange={(e) => setGender(e.target.value)}>
                <option value="">性別を選択</option>
                <option value="male">男性</option>
                <option value="female">女性</option>
                <option value="other">その他</option>
            </select>
            </p>
            </div>
            <input 
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)} placeholder="ユーザー名" required />
            <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)} placeholder="メールアドレス" required />
            <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)} placeholder="パスワード" required />
            <div>
                <small>パスワードは7文字以上8文字以下で英字と数字を両方含む必要があります</small>
            </div>
            <div>
                <p>生年月日 :
                <select value={birthYear} onChange={(e) => setBirthYear(e.target.value)} required>
                    <option value="">年を選択</option>
                    {years.map((year) => (
                        <option key={year} value={year}>{year}</option>
                    ))}
                </select>
                <select value={birthMonth} onChange={(e) => setBirthMonth(e.target.value)} required>
                    <option value="">月を選択</option>
                    {months.map((month) => (
                        <option key={month} value={month}>{month}</option>
                    ))}
                </select>
                <select value={birthDay} onChange={(e) => setBirthDay(e.target.value)} required>
                    <option value="">日を選択</option>
                    {days.map((day) => (
                        <option key={day} value={day}>{day}</option>
                    ))}
                </select>                </p>
            </div>
        <button type="submit" disabled={mutation.isLoading}>{mutation.isLoading ? '登録中...': '登録'}</button>
    </form>
    {message && <div>{message}</div>}
    </div>
    );
};

export default Register;

そして、デザインの調整をします。

Regiterディレクトリにindex.cssファイルを作成し、以下を記述します。

frontend/src/components/pages/Register/index.css
/* フォーム全体のスタイル */
form {
 display: flex;
 flex-direction: column; /* 縦に並べる */
 align-items: center; /* 中央揃え */
 max-width: 600px; /* 最大幅を600pxに設定(画面幅に合わせて調整) */
 width: 100%; /* 幅は100% */
 margin: 0 auto; /* 自動マージンで中央寄せ */
 padding: 20px; /* 内部に余白を設定 */
 box-sizing: border-box; /* パディングとボーダーを含めた幅計算 */
}

/* 各フォーム要素のスタイル */
input, select, button {
 width: 100%;
 padding: 10px; /* 内側の余白 */
 margin-bottom: 12px; /* 下に余白を追加 */
 font-size: 16px;
 border: 1px solid #ccc; /* ボーダー */
 border-radius: 6px; /* 角を丸くする */
}

/* 生年月日のセレクトボックスを横並びにする */
p {
 font-weight: bold;
 display: flex;
 gap: 8px;
 align-items: center; /* 垂直方向の中央揃え */
 margin-bottom: 12px;
}

/* 性別のセレクトボックスのスタイル */
p:last-child {
 margin-bottom: 20px; /* 性別セクションの下に余白を追加 */
}

h1 {
 text-align: center;
 margin-top: 50px;
 margin-bottom: 30px;
}


button {
 padding: 10px;
 margin: 0 auto; /* 自動余白で中央揃え */
 border: none;
 color: white;
 cursor: pointer; 
 font-size: 20px;
 font-weight: bold;
 width: 150px;
 height: 70px;
 display: block; /* ボタンをブロック要素に */
 background-color: green;
 cursor: pointer;
 margin-bottom: 30px;
 border-radius: 6px;
 margin-top: 30px;

}

button:hover {
 background-color: darkgreen;
}

input {
 margin:  10px 0; /* 上下に余白を追加 */
 width: 50%;
 padding: 8px;
 border: 1px solid #ccc; /* ボーダーの設定 */
 border-radius:  6px; /* 角を丸める */
}

small {
 font-size: 12px;
 color: gray;
 margin-bottom: 30px; /* 下に30pxの余白を追加 */
}

form {
 display: flex;
 flex-direction: column; /* 縦に並べる */
 align-items: center; /* 中央揃え */
}

select {
 width: auto;
 height: 38px;
 padding: 4px; /* 内側の余白を設定 */
 border-radius: 6px;
 margin: 0; /* 必要に応じてマージンをリセット */
 margin-bottom: 12px;
 border: 1px solid #ccc /* 枠線の色と太さ */
}

.option {
 font: gray;
}

/* アイコン選択と性別選択を横並びにする */
.file-gender-container {
 display: flex;
 gap: 16px;
 align-items: center;
 width: 100%;
}

/* ファイル入力欄のスタイル */
input[type="file"] {
 width: 300px;
 padding: 10px;
 margin-bottom: 12px;
}

すると、

新規メモ.jpeg

といった画面が作成されれば、OKです。

登録した情報は、他のファイルで結構使うので、インポートするために、srcディレクトリ内にtypesというディレクトリを作成し、tsファイルを作成します。

frontend/src/types/index.ts
export type Me = {
    user_id: string; // 7桁のユーザーID
    username: string; // ユーザー名
    email: string; //メールアドレス
    birthdate: string; // 生年月日
    gender: string; // 性別
    icon?: string; // アイコンのパス 
}

3.パスワード再設定ページ

frontend/src/components/pages/ResetPassword/index.tsx
import React, {useState} from 'react';
import { useMutation} from 'react-query';
const ResetPassword: React.FC = () => {
    const [email, setEmail] = useState('');
    const [newPassword, setNewPassword] = useState('');
    const [confirmPassword, setConfirmPassword] = useState('');
    const [message, setMessage] = useState('');
    const validatePassword = (password: string) => {
    const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/;
    return passwordRegex.test(password);
    };
    const mutation = useMutation(async () => {
        if (!validatePassword(newPassword)) {
            throw new Error('新しいパスワードは7〜8文字で、英字と数字を含めるようにしてください。');
        }
        if (newPassword !== confirmPassword) {
            throw new Error('パスワードが一致しません。')
        }
        const response = await fetch('http://localhost:8080/reset_password.php', {
            method: 'POST',
            headers: {'Content-Type' : 'application/x-www-urlencoded'},
            body: new URLSearchParams({ email, newPassword}).toString(),
        });
        if (!response.ok) {
            throw new Error('再設定メールの送信ができませんでした。');
        }
        return await response.json();
    },{
        onSuccess: (data) => {
            setMessage(data.message);
        },
        onError: (error: Error) => {
            setMessage(error.message)
        }
    });
    const handleReset = async (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate();
        if (!validatePassword(newPassword)) {
            setMessage('新しいパスワードは7〜8文字で、英字と数字を含めるようにしてください。');
            return;
        }
        if (newPassword !== confirmPassword) {
            setMessage('パスワードが一致しません。')
            return;
        }
        try {
            const response = await fetch('http://localhost:8080/reset_password.php', {
                method: 'POST',
                headers: {'Content-Type':  'application/x-www-form-urlencoded'},
                body: new URLSearchParams({email, newPassword}).toString(),
            });
            if (!response.ok) throw new Error('再設定メールの送信ができませんでした。');
            const data = await response.json();
            setMessage(data.message);
        } catch (error) {
            setMessage('エラーが発生しました。');
        }
    };
    return (
        <div>
            <h1>パスワード再設定</h1>
            <form onSubmit={handleReset}>
                <input 
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)} placeholder="登録済みメールアドレス"  required />
                <input
                type="password"
                value={newPassword}
                onChange={(e) => setNewPassword(e.target.value)}
                placeholder="新しいパスワード"
                required />
                <div>
                    <small>パスワードは7文字以上8文字以下で英字と数字を両方含む必要があります</small>
                </div>
                <input
                type="password"
                value={confirmPassword}
                onChange={(e) => setConfirmPassword(e.target.value)}
                placeholder="パスワード確認"
                required />
                <button type="submit" disabled={mutation.isLoading}>{mutation.isLoading ? '処理中...' :'ID・パスワードを再設定'}</button>
            </form>
            {message && <div>{message}</div>}
        </div>
    );
};
export default ResetPassword;

デザインの調整をします。

ResetPasswordディレクトリにindex.cssファイルを作成し、以下を記述します

frontend/src/components/pages/ResetPassword/index.css
h1 {
  text-align: center;
  margin-top: 50px;
  margin-bottom: 30px;
}

button {
  padding: 10px;
  margin: 0 auto; /* 自動余白で中央揃え */
  border: none;
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 180px;
  height: 80px;
  display: block; /* ボタンをブロック要素に */
  background-color: palevioletred;
  cursor: pointer;
  margin-bottom: 30px;
  border-radius: 4px;
  margin-top: 30px;

}

button:hover {
  background-color: orchid;
}

input {
  margin:  10px 0; /* 上下に余白を追加 */
  width: 30%;
  padding: 8px;
  border: 1px solid #ccc; /* ボーダーの設定 */
  border-radius:  4px; /* 角を丸める */
}

small {
  font-size: 12px;
  color: gray;
  margin-bottom: 30px; /* 下に30pxの余白を追加 */
}

form {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
}

.password {
  text-align: center;
}

こういうページになれば、OKです。

新規メモ.jpeg

4.勤怠管理ページ

「出勤」や「退勤」などのボタンを押した際、トースト通知が出るようにするために、reactーtoastifyを使用するので、

tsconfig.jsonファイルに

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
+ "typeRoots": [
+ "./node_modules/@types"
+ ]
  },
  "include": [
    "src"
  ]
}

以上を追記します。

frontend/src/components/pages/TimeTracking/index.tsx
import React, {useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ToastContainer, toast} from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import { useMutation } from 'react-query';

const TimeTracking: React.FC = () => {
    const [message, setMessage] = useState('');
    const [currentTime, setCurrentTime] = useState<{ date: {year: string; monthDay: string}; time: string}>
    ({  date: {year: '', monthDay: ''},
        time: ''
    });
    const [lastCheckInTime, setLastCheckInTime] = useState<Date | null>(null);
    const [lastBreakStartTime, setLastBreakStartTime] = useState<Date | null>(null);
    const [lastCheckOutTime, setLastCheckOutTime] = useState<Date | null>(null);
    const [lastBreakEndTime, setLastBreakEndTime] = useState<Date | null>(null);
    const [reportSubmissionFailed, setReportSubmissionFailed] = useState<boolean>(false);
    const navigate = useNavigate();


    // 時刻が更新する関数
    const updateTime = () => {
        const now = new Date();

        // 年の部分
        const year =  `${now.getFullYear()}年/`;
        const monthDay =  `${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2,'0')}日`;

        const formattedTime = `${now.getHours().toString().padStart(2, '0')}:
        ${now.getMinutes().toString().padStart(2, '0')}:
        ${now.getSeconds().toString().padStart(2,'0')}`;
        
        // 曜日の取得
        const daysOfWeek = ['','', '', '', '', '', ''];
        const day = daysOfWeek[now.getDay()];
        
        setCurrentTime({ date: {year, monthDay: `${monthDay}(${day})`}, time: formattedTime });
    };

    // コンポーネントがマウントされた時に時間が更新し、1秒ごとに再更新
    useEffect(() => {
        updateTime();
        const timer = setInterval(updateTime, 1000);

    // クリーンアップ関数
    return () => clearInterval(timer);
    }, []);

    const handleAttendance = async (type: 'in' | 'out' | 'breakStart' | 'breakEnd') => {
        const response = await fetch('http://localhost:8080/time_tracking.php', {
            method: 'POST',
            headers: {'Content-Type': 'application/x-www-form-urlencoded'},
            body: new URLSearchParams({ type}).toString(),
        });

        return response.json();
    };

    const mutation = useMutation(handleAttendance, {
        onSuccess: (data, variables) => {
            setMessage(data.message);
            const now = new Date();
            switch (variables) {
                case 'in':
                    toast.success('お仕事頑張ってください。', { autoClose: 3000, position:"top-center", style: {backgroundColor: 'blue', color: 'white'}});
                    setLastCheckInTime(now);
                    break;
                case 'out':
                    toast.success('おつかれさまでした。', { autoClose: 3000, position: "top-center", style: {backgroundColor: 'red', color: 'white'}});
                    setLastCheckOutTime(now);
                    navigate('/login');
                    break;
                case 'breakStart':
                    toast.info('休憩開始しました。', { autoClose: 3000, position: "top-center", style: {backgroundColor: 'green', color: 'white'}});
                    setLastBreakStartTime(now);
                    break;
                case 'breakEnd':
                    toast.info('休憩終了しました。', { autoClose: 3000, position: "top-center", style: {backgroundColor: 'purple', color: 'white'}});
                    setLastBreakEndTime(now);
                    break;
            }
        },
        onError: () => {
            setMessage('登録できませんでした。');
        }
    });

    const handleButtonClick = (type: 'in' | 'out' | 'breakStart' | 'breakEnd') => {
        const now = new Date();

        // 出勤していない場合のエラーメッセージ
        if ((type === 'in' || type === 'breakStart' || type === 'breakEnd') && !lastCheckInTime ) {
            setMessage('まだ出勤していません。')
            return;
        }
        if (type === 'out' && !reportSubmissionFailed) {
            setMessage('作業日報が未送信です。退勤する前に作業日報を送信してください。');
            return;
        }
        if (type === 'breakEnd' && !lastBreakStartTime) {
            setMessage('休憩を開始していません.');
            return;
        }

        let lastTime: Date | null = null;
        let action: string = '';

        switch (type) {
            case 'in':
                lastTime = lastCheckInTime;
                action = '出勤';
                break;
            case 'breakStart':
                lastTime = lastBreakStartTime;
                action = '休憩開始';
                break;
            case 'out':
                lastTime = lastCheckOutTime;
                action = '退勤';
                break;
            case 'breakEnd':
                lastTime = lastBreakEndTime;
                action = '休憩終了';
                break;
        }

        // 時間差の計算とメッセージの設定
        if (lastTime) {
            const diffMinutes = Math.floor((now.getTime() - lastTime.getTime()) / (1000 * 60));
            if (diffMinutes > 60) {
                setMessage(formatMessage(diffMinutes, action));
                return;
            } else {
                setMessage(`${diffMinutes}分前に${action}ボタンを押しています。`);
                return;
            }
        }
        mutation.mutate(type);
    };

    const formatMessage = (diffMinutes: number, action: string) => {
        const hours = Math.floor(diffMinutes / 60);
        const minutes = diffMinutes % 60;
        return `${hours}時間${minutes}分前に${action}ボタンを押しています。`;
    };

    // 作業日報ボタンのハンドラ
    const handleApp = () => {
        if (!lastCheckInTime) {
            setMessage('出勤してから作業日報を記入してください。');
            return;
        }
        navigate('/report'); // 作業日報ページに遷移
    }

    return (
        <div>
            <ToastContainer />
            <div className="time-container">
                <div className="date">
                    <span className="date-year">{currentTime.date.year}</span>
                    <span className="date-monthday">{currentTime.date.monthDay}</span>
                </div>
                <div className="time">
                    {currentTime.time}
                </div>  
            </div>  
            <div className="button-container">
            <button className="button button-in" onClick={() => handleButtonClick('in')}>出勤</button>
            <button className="button button-out"   onClick={() => handleButtonClick('out')}>退勤</button>
            <button className="button button-breakStart"  onClick={() => handleButtonClick('breakStart')}>休憩開始</button>
            <button className= "button button-breakEnd"   onClick={() => handleButtonClick('breakEnd')}>休憩終了</button>
            <button className="button button-report"  onClick={handleApp}>作業日報</button>
            </div>
            {message && <div className="error-message">{message}</div>}

        </div>
    );
};

export default TimeTracking;

デザインの調整

frontend/src/components/pages/TimeTracking/index.css
.time-container {
  text-align: center;
  margin-bottom: 138px; /* 下に138pxの余白を追加 */
}

.date {
  margin-top: 120px;
  
}

.date-year {
  color: gray;
  font-size: 15px;
}

.date-monthday {
  color: black;
  font-size: 23px;
  font-weight: bold;
}

.time {
  font-size: 55px;
  font-weight: bold;
}

.button-container {
  display: flex; /* 横並びにする */
  justify-content: space-evenly; /* ボタンの間隔を均等にする */
  gap: 5px; /* ボタンの間隔を5pxに設定 */
  margin-top: 20px; /* 上部の余白を追加 */

}


.button {
  padding: 20px;
  border: none;
  border-radius:  100px; /* 丸くする */
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 180px;
  height: 180px;
  display: flex;
  align-items: center; /* 縦中央揃え*/
  justify-content: center; /* 横中央揃え */

}

.button-in {
  background-color: blue; /* 出勤ボタンの色*/
}

.button-out {
  background-color: red; /* 退勤ボタンの色 */
}

.button-breakStart {
  background-color: green; /* 休憩開始ボタンの色 */
}

.button-breakEnd {
  background-color: purple; /* 休憩終了ボタンの色 */
}

.button-report {
  background-color: orange; /* 作業日報ボタンの色 */
}

.error-message {
  margin-top: 20px;
  text-align: center;
  color: red;
}

すると、こんな画面になります。

新規メモ.jpeg

上部に現在の時間が秒単位で更新される形で表示され、

下の方に勤怠ボタンが横一列に並ぶ形になればOKです。

5.マイページ

ユーザーそれぞれ専用の勤怠実績を表示するためのページを作成します。

frontend/src/components/pages/Mypage/index.tsx
import React, { useEffect, useState } from 'react';
import { Me } from '../../../types';
import './index.css';
const Mypage: React.FC = () => {
    const [attendanceData, setAttendanceData] = useState<any[]>([]);
    const [summary, setSummary] = useState({ actualWorkHours: 0, remainingActualWorkMinutes: 0, totalBreakHours: 0, remainingBreakMinutes: 0});
    const [user, setUser] = useState<Me | null>(null);
    const [isDataVisible, setIsDataVisible] = useState(false);
    const [message, setMessage] = useState('');
    const [currentPage, setCurrentPage] = useState<number>(1);
    const [totalPages, setTotalPages] = useState<number>(0);
    const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
    const [selectedMonth, setSelectedMonth] = useState<number>(new Date().getMonth() + 1);
    const [totalMonthlyWorkMinutes, setTotalMonthlyWorkMinutes] = useState<number>(0); // 月ごとの合計実働時間
    const [isNextMonthFirstDayOfMonth, setIsNextMonthFirstDayOfMonth] = useState<boolean>(false); // 1日かどうか
    const currentMonth = new Date().getMonth() + 1;
    useEffect(() => {
        fetchUserData();
        fetchAttendanceData();
        checkIfNextMonthFirstDay();
    },[currentPage, selectedYear, selectedMonth]);
        const fetchUserData = () => {
            const storedUser = localStorage.getItem('user');
            if (storedUser) {
                setUser(JSON.parse(storedUser)); // ローカルストレージからユーザー情報を取得
            }
        }
        
        // 次の月の一日以降かどうかをチェック
        const checkIfNextMonthFirstDay = () => {
            const today = new Date();
            const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1);
            const isFirstDay = today.getDate() === 1 && today.getMonth() === nextMonth.getMonth() - 1;
            
                setIsNextMonthFirstDayOfMonth(isFirstDay);
            };
        const  handleYearChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
            setSelectedYear(Number(event.target.value));
            setCurrentPage(1);
        }
        const handleMonthChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
            setSelectedMonth(Number(event.target.value));
            setCurrentPage(1);
        };
        const fetchAttendanceData = async () => {
        const response = await fetch(`http://localhost:8080/time_tracking.php?page=${currentPage}&year=${selectedYear}&month=${selectedMonth}`, {
            method: 'GET',
            headers: {'Content-Type': 'application/json'},
        });
        if (response.ok) {
            const data = await response.json();
            setAttendanceData(data.attendance);
            setSummary({
                totalBreakHours: data.totalBreakHours,
                remainingBreakMinutes: data.remainingBreakMinutes,
                actualWorkHours: data.actualWorkHours,
                remainingActualWorkMinutes: data.remainingActualWorkMinutes
            });
            setTotalPages(data.totalPages); // APIから取得した総ページを設定
            setIsDataVisible(true);
            calculateMonthlyWorkHours(data.attendance); // 月ごとの合計を計算
        } else {
            setMessage('データを取得できませんでした。')
        }
    };
    const handlePageChange = (page: number) => {
        setCurrentPage(page);
    };
    
    const calculateMonthlyWorkHours = (attendance: any[]) => {
        const totalMinutes = attendance.reduce((acc, entry) => {
            // 実働時間を分単位で取得し、合計
            const workMinutes = entry.actualWorkHours * 60 + entry.remainingActualWorkMinutes;
            return acc + workMinutes;
        }, 0);
        setTotalMonthlyWorkMinutes(totalMinutes); 
    };
    const formatTime = (totalMinutes: number) => {
        const hours = Math.floor(totalMinutes / 60);
        const minutes = totalMinutes % 60;
        const seconds = 0;
        return `${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(seconds).padStart(2,'0')}`;
    }
    const handleAttendanceData = () => {
        fetchAttendanceData();
    };
    return (
        <div>
            {user && user.icon && (
                <div>
                    <img src={`http://localhost:8080${user.icon}`} alt="ユーザー" width={100} height={100}/>
                </div>
            )}
            <h2>ようこそ{user?.username}さん</h2> 
            <button onClick={handleAttendanceData}>勤怠実績一覧</button>
            { /* 勤務データの表示 */ }
            {isDataVisible && (
                <div>
                    <h2>勤務データ</h2>
                    { /* 年と月の選択部分をここに追加 */ }
                    <div>
                        <label>
                            <select value={selectedYear} onChange={handleYearChange} >
                                {Array.from({length: 5}, (_, i) => selectedYear - i).map(year => (
                                    <option key={year} value={year}>{year}</option>
                                ))}
                            </select>年</label>
                        <label>
                            <select value={selectedMonth} onChange={handleMonthChange}>
                                {Array.from({ length: 12}, (_, i) => i + 1).map(month => (
                                    <option key={month} value={month}>{month}</option>
                                ))}
                            </select>月</label>
                    </div>
                    <table>
                        <tbody>
                            <tr>
                                <th>ID</th>
                                <th>名前</th>
                                <th>作業内容</th>
                                <th>備考</th>
                                <th>出勤時間</th>
                                <th>退勤時間</th>
                                <th>実働時間</th>
                                <th>総休憩時間</th>
                            </tr>
                        </tbody>
                        <tbody>
                            {attendanceData.map((entry,index) => (
                                <tr key={index}>
                                    <td>{entry.id}</td>
                                    <td>{entry.username}</td>
                                    {entry.type === 'report' ? (
                                        <>
                                        <td>{entry.title}</td>
                                        <td>{entry.content}</td>
                                        </>
                                    ) : (
                                        <>
                                        <td colSpan={2}>N/A</td></>
                                    )}
                                    <td>{entry.startTime || '未登録'}</td>
                                    <td>{`${summary.actualWorkHours || 0}:${summary.remainingActualWorkMinutes || 0}:00`}</td>
                                    <td>{`${summary.totalBreakHours || 0}:${summary.remainingBreakMinutes || 0}:00`}</td>
                                </tr>
                            ))}
                        </tbody>
                    </table>
                    {isNextMonthFirstDayOfMonth && (
                        <div>{currentMonth}月の合計実働時間: {formatTime(totalMonthlyWorkMinutes)}</div>
                    )}
                    <div>
                        <button className="prev-button" onClick={() => handlePageChange(currentPage - 1)} disabled={currentPage === 1}>前へ</button>
                        { /* ページ番号の表示 */}
                        {Array.from({ length: Math.min(totalPages, 3) }, (_, index) => (
                            <button key={index} onClick={() => handlePageChange(index + 1)} disabled={currentPage === index + 1} className={`page-button ${currentPage === index + 1 ? 'active-page': ''}`}>
                                {index + 1}
                            </button>
                        ))}
                        <button className="next-button" onClick={() => handlePageChange(currentPage + 1)} disabled={currentPage === Math.min(totalPages, 3)}>次へ</button>
                        </div>
                </div>
            )}
            {message && <div>{message}</div>}
        </div>
    );
}
export default Mypage;

デザインの調整

frontend/src/components/Mypage/index.css
.user-container {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
  max-width: 600px; /* 最大幅を600pxに設定(画面幅に合わせて調整) */
  width: 100%; /* 幅は100% */
  margin: 0 auto; /* 自動マージンで中央寄せ */
  padding: 20px; /* 内部に余白を設定 */
  box-sizing: border-box; /* パディングとボーダーを含めた幅計算 */
}

h2 {
  text-align: center;
  margin-top: 50px;
  margin-bottom: 30px;
}

table {
  width: 100px;
  border-collapse: collapse;
}

th, td {
  border: 1px solid #dddddd;
  text-align: left;
  padding: 8px;
}

th {
  background-color: #f2f2f2;
}

tr:nth-child(even) {
  background-color: #f9f9f9;
}

tr:hover {
  background-color: #f1f1f1;
}

.user-container {
  align-items: center;

}

button.kintai{
  padding: 10px;
  margin: 0 auto; /* 自動余白で中央揃え */
  border: none;
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 150px;
  height: 70px;
  display: block; /* ボタンをブロック要素に */
  background-color: rgb(139, 12, 139);
  cursor: pointer;
  margin-bottom: 30px;
  border-radius: 6px;
  margin-top: 30px;

}
button.kintai:hover {
  background-color: rgb(64, 4, 64);
}

button.prev-button {
  width: 80px;
  font-weight: bold;
  background-color: gray;
  color: white;
  border: none;
  padding: 10px;
  border-radius: 4px;
  cursor: pointer;
}

button.prev-button:hover {
  background-color: darkgray;
}

button.next-button {
  width: 80px;
  font-weight: bold;
  background-color: blue;
  color: white;
  border: none;
  padding: 10px;
  border-radius: 4px;
  cursor: pointer;
}

button.next-button:hover {
  background-color: darkblue;
}

.active-page {
  font-weight: bold;
  color: blue;
  border-bottom: 2px solid blue;
}

.page-button {
  font-size: 18px;
  background-color: transparent; /* 背景を透明にする */
  border: none; /* ボーダーをなくす */
  color: blue;
  cursor: pointer;
  margin: 0 15px; /* 左右に10pxの余白を追加 */
}

.page-button:hover {
  text-decoration: underline;
}

今は、バックエンドであるPHPとの連携ができていないので、マイページは、

ようこそ、さん.jpeg

ここまででしか表示できていません。

6.作業日報ページ

frontend/src/components/report/index.tsx
import './index.css';
import React, { useState } from 'react';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/reactToastify.css';
import { useMutation } from 'react-query';
interface ReportProps {
    onSubmissionFail: () => void;
}
const Report: React.FC<ReportProps> = ({onSubmissionFail}) => {
    const [newTask, setNewTask] = useState<{ author_name: string; task: string; content: string}>({
        author_name: '',
        task: '',
        content: '',
    });
    const [message, setMessage] = useState<string>('');
    const taskOptions = ['ピッキング', '伝票整理', '検品', '箱詰め', '運搬','仕分け','データ入力'];
    const mutation = useMutation(async () => {
        const response = await fetch('http://localhost:8080/time_tracking.php', {
            method: 'POST',
            headers: {'Content-Type': 'application/x-www-form-urlencoded'},
            body: new URLSearchParams(newTask).toString(),
        });
        const data = await response.json();
        return data;
    }, {
        onSuccess: (data) => {
            setMessage(data.message);
            setNewTask({ author_name: '', task: '', content: ''}); // フォームをリセット
            toast.success('送信しました。', { autoClose: 3000, position: "top-center", style: {backgroundColor: 'orange', color: 'white'}});
        },
        onError: () => {
            setMessage('作業内容の送信に失敗しました。');
            onSubmissionFail();
        }
    });
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate();
    };
    return (
        <div>
            <ToastContainer />
            <h1>作業日報</h1>
            <form onSubmit ={handleSubmit} >
                    <select
                    value={newTask.task}
                    onChange={e => setNewTask({ ...newTask, task: e.target.value })} 
                    required>
                        <option value="" disabled>作業内容を選択</option>
                        {taskOptions.map((task, index) => (
                            <option key={index} value={task}>{task}</option>
                        ))}
                    </select>
                    <textarea
                    value={newTask.content}
                    onChange={e => setNewTask({ ...newTask, content: e.target.value})} 
                    placeholder="伝えたいことがない場合は、「特になし」と記入してください。"
                     required />
                     <button type="submit" disabled={mutation.isLoading}>{mutation.isLoading ? '送信中...':'送信する'}</button>
            </form>
            {message && <div>{message}</div>}
        </div>
    );
};
export default Report;

そして、デザインを整えるために、

Reportディレクトリ内に、index.cssファイルを作成します。

frontend/src/components/report/index.css
form {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
  max-width: 600px; /* 最大幅を600pxに設定(画面幅に合わせて調整) */
  width: 100%; /* 幅は100% */
  margin: 0 auto; /* 自動マージンで中央寄せ */
  padding: 20px; /* 内部に余白を設定 */
  box-sizing: border-box; /* パディングとボーダーを含めた幅計算 */
}

h1 {
  text-align: center;
  margin-top: 50px;
  margin-bottom: 30px;
}


textarea {
  margin:  10px 0; /* 上下に余白を追加 */
  width: 80%;
  height: 80px;
  padding: 8px;
  border: 1px solid #ccc; /* ボーダーの設定 */
  border-radius:  6px; /* 角を丸める */
}

select {
  margin:  10px 0; /* 上下に余白を追加 */
  width: 30%;
  padding: 10px;
  border: 1px solid #ccc; /* ボーダーの設定 */
  border-radius:  6px; /* 角を丸める */
}

button {
  padding: 10px;
  margin: 0 auto; /* 自動余白で中央揃え */
  border: none;
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 180px;
  height: 80px;
  display: block; /* ボタンをブロック要素に */
  background-color: orange;
  cursor: pointer;
  margin-bottom: 30px;
  border-radius: 4px;
  margin-top: 30px;

}

button:hover {
  background-color: darkorange;
}

.error {
  margin-top: 20px;
  text-align: center;
  color: red;
}

すると、作業日報ページは、

新規メモ.jpeg

のようになります。

ヘッダーメニューとして、グローバルナビゲーションの作成します。

srcディレクトリ内のcomponentsディレクトリ内にsharedディレクトリを作成し、Globalnav.tsxファイルを作成します。

frontend/src/components/shared/Globalnav.tsx
import React from 'react';
import { Link } from 'react-router-dom';
import { Me } from './types';

type Props = {
  user: Me | null;
};

const GlobalNav: React.FC<Props> = ({user}) => {
    return (      
        <nav style={styles.nav}>
        { !user ? (
          // ユーザーが未ログインの場合、新規登録ボタンを表示
          <Link to="/register" style={styles.register}>新規登録</Link>
        ) : (
          // ログイン状態の場合、マイページと退会するボタンを表示
          <>
          <Link to="/leave" style={styles.link} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>退会する</Link>
         <Link to="/mypage" style={styles.iconLink}>
          { user.icon ? (<img src={user.icon} alt="マイページ" style={styles.icon}/>
        ):(
        <img src={`${process.env.PUBLIC_URL}/images/icon/user.png`} alt="マイページ" style={styles.icon} />
        )}
         </Link>
         </>
        )}
         </nav>
    );
};

const handleMouseEnter = (e:React.MouseEvent<HTMLAnchorElement>) => {
  (e.currentTarget.style as CSSStyleDeclaration).backgroundColor = styles.linkHover.backgroundColor || '';
};

const handleMouseLeave = (e:React.MouseEvent<HTMLAnchorElement>) => {
  (e.currentTarget.style as CSSStyleDeclaration).backgroundColor = styles.link.backgroundColor || '';
}


const styles: {[key: string]: React.CSSProperties} = {
  nav: {
    display: 'flex',
    position: 'fixed',
    top: 0, // 画面上部に配置
    left: 0, // 左端に配置
    width: '100vw', // ビューポートの横幅を指定
    justifyContent : 'flex-end', // コンテンツを右寄せ
    alignItems:  'center',
    padding:  '10px, 20px', // 横の余白を少し増やす
    backgroundColor: '#3d3d3d',// 暗い灰色
    boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
    zIndex: 1000, // 他の要素の上に表示されるように表示
  },

  register: {
    display: 'flex',
    justifyContent: 'center', // 水平方向に中央揃え
    alignItems: 'center',
    textDecoration: 'none',
    width: '8%', 
    color: 'white',
    margin: '0 50px',
    fontSize: '18px',
    padding: '10px 10px',
    borderRadius: '6px',
    backgroundColor: 'green', // 緑色
    transition: 'background-color 0.3s',
  },

  registerHover: {
    backgroundColor: 'darkgreen', // ホバー時の色
  },


  link: {
    textDecoration: 'none',
    color: 'white',
    margin: '0 15px',
    fontSize: '16px',
    padding: '8px 12px',
    borderRadius: '4px',
    backgroundColor: '#ff69b4', // ピンク色
    transition: 'background-color 0.3s'
  },

  linkHover: {
    backgroundColor: '#e91e63', // ホバー時の色
  },

  iconLink: {
    marginRight: 'auto',
  },

  icon: {
    width: '40px',
    height: '40px',
  }
};

export default GlobalNav;

ログインできていない状態だと、下のような画像↓

新規メモ.jpeg

そして、ログインできたら、「退会する」ボタンや「マイページ」へ繋がるように

アイコンのボタンを配置するように変わるようなデザインにします。

バックエンド

次は、バックエンド側の改良です。

勤怠管理アプリに必要な認証関連、ユーザー登録、パスワード再設定、勤怠管理、退会処理などをデータベースに反映し、フロントエンドとの連携を可能にするため、phpディレクトリにファイルを追加していきます。

フロントエンドで登録されたユーザー情報をデータベースに反映するための処理

php/register.php
<?php
// CORSヘッダーを設定
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, GET");
header("Access-Control-Allow-Headers: Content-Type");
// データベース接続設定
$host = 'mysql';
$user = 'user';
$password = 'password';
$dbname = "kintai";
$conn = new mysqli($host, $user, $password, $dbname);
if ($conn->connect_error ) {
    die("接続失敗:" . $conn->connect_error);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = $_POST['username'];
    $email = $_POST['email'];
    $password = password_hash($_POST['password'],  PASSWORD_DEFAULT);
    $birthdate = $_POST['birthdate'];
    $gender = $_POST['gender'];
    // アイコンの処理
    $iconPath = null;
    if (isset($_FILES['icon' ]) && $_FILES['icon']['error'] === UPLOAD_ERR_OK) {
        $iconTmpPath = $_FILES['icon']['tmp_name'];
        $iconOriginalName = basename($_FILES['icon']['name']);
        $iconPath ='/public/images/icon' . uniqid() . '_' . $iconOriginalName; // 一意なファイル名
    }
    // アップロードのディレクトリが存在しない場合は作成
    if (!is_dir('public/images/icon')) {
        mkdir('public/images/icon', 0777, true);
    }
    // ファイルを移動
    if (!move_uploaded_file($iconTmpPath, $iconPath)) {
        echo json_encode(['message' => 'アップロードできませんでした>']);
        exit;
    }
    // 7桁の数字のIDを発行する関数
    function generateNumericId($length = 7) {
        $id = '';
        for ($i = 0; $i < $length; $i++) {
            $id .= rand(0,9);
        }
        return $id;
    }
    // 重複しないIDを生成するループ
    do {
        $userId = generateNumericId();
        // すでに同じIDが存在しないか確認
        $checkStmt = $conn->prepare("SELECT * FROM users WHERE user_id = ?");
        $checkStmt->bind_param("s", $userId);
        $chechStmt->execute();
        $result = $checkStmt->get_result();
        $checkStmt->close();
    } while ($result->num_rows > 0); // すでにID番号がある場合は、再生成
    // SQL文の準備
    $sql = "INSERT INTO users (user_id, username,  email, password, birthdate, gender, icon) VALUES (?, ?, ?, ?, ?, ?, ?)";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param("ssssss", $userId, $username, $email, $password, $birthdate, $gender, $iconPath);
    if ($stmt->execute()) {
        // メール送信設定
        $to = $email;
        $subject = "登録完了のお知らせ";
        $message = "ID番号: $userId\nIDは大切に保管してください。";
        $headers = "From: [IDを送信するメールアドレス(例:noreply012@mail.com)]";
        // メール送信
        if (mail($to, $subject, $message, $headers)) {
            echo json_encode([
                'message' => '登録が完了しました。メールを確認してください。',
                'user_id' => $userId,
                'username' => $username,
                'iconpath' => $iconPath
        ]);
            
        } else {
            echo json_encode(['message' => 'メールの送信に失敗しました。']);
        }
    } else {
        echo json_encode(['message'=> '登録できませんでした。']);
    }
    $stmt->close();
} elseif ($_SERVER['REQUEST_METHOD'] === 'GET') {
    // 作業者の取得処理
    $result = $conn->query("SELECT user_id, username FROM users");
    $users = [];
    while ($row = $result->fetch_assoc()) {
        $users[] = $row;
    }
    echo json_encode(['users' => $users]);
}
$conn->close();
?>

データベースに登録されたユーザー情報の認証やその関連を行うための処理

php/auth.php
<?php
// CORSヘッダーを設定
headers("Access-Control-Allow-Origin: *");
headers("Access-Control-Allow-Methods: POST");
headers("Access-Control-Allow-Headers: Content-Type");
//データベース接続設定
$host = 'mysql';
$user = 'user';
$password = 'password';
$dbname = 'kintai';
$conn = new mysqli($host, $user, $password, $dbname);
if ($conn->connect_error) {
    die("接続失敗:" . $conn->connect_error);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $userId = $_POST['userId']; // ID番号を取得
    $password = $_POST['password'];
    // ID番号でユーザーを検索
    $sql = "SELECT * FROM users WHERE id = ?"; // ID列の名前を確認してください。
    $stmt = $conn->prepare($sql);
    $stmt->bind_param("i", $userId); // IDは整数の場合
    $stmt->execute();
    $result = $stmt->get_result();
    if ($result->num_rows === 0) {
        echo json_encode(['success' => false, 'message' => 'ユーザーが見つかりません。']);
        exit;
    }
    $user = $result->fetch_assoc();
    // パスワードの確認
    if (password_verify($password, $user['password'])) {
        echo json_encode(['success' => true]);
    } else {
        echo json_encode(['success' => false, 'message' => 'パスワードが正しくありません。']);
    }
    $stmt->close();
}
$conn->close();
?>

勤怠情報をデータベースに反映し、管理するための処理

php/time_tracking.php
<?php
// CORSヘッダーを設定
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, GET");
header("Access-Control-Allow-Headers: Content-Type");
session_start(); // セッション開始
$userId = $_SESSION['user_id']; // セッションからユーザーIDを取得
$host = 'mysql';
$user = 'user';
$password = 'password';
$dbname = 'kintai';
$conn = new mysqli($host, $user, $password, $dbname);
if ($conn->connect_error) {
    die("接続失敗:" . $conn->connect_error);
}
// 年と月を取得
$year = isset($_GET['year']) ? (int)$_GET['year']: date('Y');
$month = isset($_GET['month']) ? (int)$_GET['month']: date('n');
$limit = 31; //1ページあたりのデータ数
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$offset = ($page - 1) * $limit;
// 作業内容の処理を行う
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $title = $_POST['title'];
    $content = $_POST['content'];
// 作業内容をデータベースに保存するクエリ
$sql = "INSERT INTO reports (user_id, title, content) VALUES (?, ?, ?)";
$stmt = $conn->prepare($sql);
$stmt->bind_param("iss", $userId, $title, $content);
if ($stmt->execute()) {
    echo json_encode(['message' => '登録が完了しました。']);
} else {
    echo json_encode(['message' => '登録できませんでした。']);
}
$stmt->close();
exit;
}
// ここで出勤、退勤、休憩の処理を行う
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['type'])) {
    $type = $_POST['type'];
    // 出勤・退勤 ・休憩の処理をデータベースに保存
    if ($type === 'in' || $type === 'out' || $type === 'breakStart' || $type === 'breakEnd') {
        if ($type === 'in' && $count >= 1) {
            echo json_encode(['message' => '出勤は一日一回です。']);
            exit;
        }
         
        
        // 現在の時刻を取得
        $currentTime = date('Y-m-d H:i:s');
            
        // 各種勤務時間をデータベースに保存するクエリ
        $sql = "INSERT INTO attendance (user_id, type, time) VALUES (?, ?, ?)";
        $stmt = $conn->prepare($sql);
        $stmt->bind_param("iss",  $userId, $type, $currentTime);
        if ($stmt->execute()) {
            echo json_encode(['message' => '登録が完了しました。']);
        } else {
            echo json_encode(['message' => '登録できませんでした。']);
        }
        $stmt->close();
        exit;
    } 
}
// 勤務時間の集計
$sql = "SELECT type, time FROM attendance WHERE user_id = ? AND time >= DATE_SUB(NOW(), INTERVAL 1 MONTH)";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$attendanceData = [];
$workStartTime = null;
$workEndTime = null;
$totalWorkMinutes = 0;
$totalBreakMinutes = 0;
$breakStartTime = null;
// 勤務データの取得
$attendanceQuery = $conn->prepare("SELECT * FROM attendance WHERE user_id = ? AND YEAR(time) = ?  ORDER BY time DESC LIMIT ? OFFSET ?");
$attendanceQuery->bind_param("iiiii", $userId, $year, $month, $limit, $offset);
$attendanceQuery->execute();
$attendanceResult = $attendanceQuery->get_result();
while ($row = $attendanceResult->fetch_assoc()) {
    $attendanceData[] = $row;
}
// 勤務時間の計算
while ($row = $result->fetch_assoc()){
    $type = $row['type'];
    $time = new DateTime($row['time']);
    if ($type === 'in' ) {
        $workStartTime = $time;
    } elseif ($type === 'out') {
        $workEndTime = $time;
    } elseif ($type === 'breakStart') {
        $breakStartTime = $time;
    } elseif ($type === 'breakEnd') {
        $breakEndTime = $time;
        // 休憩時間を計算
        if (isset($breakStartTime) && isset($breakEndTime)) {
            $breakDuration = $breakStartTime->diff($breakEndTime);
            $totalBreakMinutes += ($breakDuration->h * 60) + $breakDuration->i;
            
        }
    }
}
// 作業日報を取得
$sql = "SELECT title, content FROM reports WHERE user_id = ?";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $userId);
$stmt->execute();
$reportResult = $stmt->get_result();
// 作業日報をAttendanceDataに追加
while ($row = $reportResult->fetch_assoc()) {
    $attendanceData[] = [
        'type' => 'report', // タイプを追加
        'title' => $row['title'],
        'content' => $row['content'],
    ];
}
// 勤務時間の計算
if ($workStartTime && $workEndTime) {
    $workDuration = $workStartTime->diff($workEndTime);
    $totalWorkMinutes = ($workDuration->h * 60) + $workDuration->i;
    $workStartTime = null; // 次の勤務のためにリセット
    
    // 15分単位にまとめる
    $roundedWorkMinutes = round($totalWorkMinutes / 15) * 15;
    $totalWorkHours = floor($roundedWorkMinutes / 60);
    $remainingWorkMinutes = $roundedWorkMinutes % 60;
} else {
    $totalWorkHours = 0;
    $remainingWorkMinutes = 0;
}
// データをJSON形式で返す 
$total_posts_result = $conn->query("SELECT COUNT(*) FROM attendance WHERE user_id = $userId AND YEAR(time) = $year AND MONTH(time) = $month");
$total_posts = $total_posts_result->fetch_row()[0];
$total_pages = ceil($total_posts / $limit);
// 休憩時間を15分単位にまとめる
$roundedBreakMinutes = round($totalBreakMinutes / 15) * 15;
$totalBreakHours = floor($roundedBreakMinutes / 60);
$remainingBreakMinutes = $roundedBreakMinutes % 60;
// 実働時間の計算
$actualWorkMinutes = max(0, $totalWorkMinutes - $totalBreakMinutes);
$roundedActualWorkMinutes = round($actualWorkMinutes / 15) * 15;
$actualWorkHours = floor($roundedActualWorkMinutes / 60);
$remainingActualWorkMinutes =  $roundedActualWorkMinutes % 60;
 // データをJSON形式で返す
echo json_encode([
    'attendance' => $attendanceData,
    'totalBreakHours' => $totalBreakHours,
    'remainingBreakMinutes' => $remainingBreakMinutes,
    'actualWorkHours' => $actualWorkHours,
    'remainingActualWorkMinutes' => $remainingActualWorkMinutes,
    'total_pages' => max(1, $total_pages),
]);
$conn->close();
?>

再設定したパスワードをデータベースに反映させるためのコード

(php/reset_password.php)

php/reset_password.php
<?php
header("Content-Type:  application/json" );
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST");
header("Access-Control-Allow-Headers: Content-Type");
// データベース接続設定
$host = 'mysql';
$dbname = 'kintai';
$user = 'user';
$password = 'password';
$conn = new mysqli($host, $dbname, $user, $password);
if ($conn->connect_error) {
    die(json_encode(['message'=> '接続失敗:' . $conn->connect_error]));
}
// POSTリクエストの処理
$email = isset($_POST['email']) ? $_POST['email'] : '';
$newPassword = isset($_POST['newPassword']) ? $_POST['newPassword'] : '';
// パスワードの検証
if (!preg_match('/^(?=.*[a-zA-Z])(?=.*\d)[A-Za-z\d]{7,8}$/', $newPassword)) {
    echo json_encode(['message' => '無効なパスワードです。']);
    exit;
}
// ユーザーの存在確認
$stmt = $conn->prepare("SELECT * FROM users WHERE email = ?");
$stmt->bind_param("s", $email);
$stmt->execute();
$user = $stmt->get_result()->fetch_assoc();
if (!$user) {
    echo json_encode(['message' => 'このメールアドレスは登録されていません。']);
    exit();
}
// パスワードの更新処理
$stmt = $conn->prepare("UPDATE users SET password = ? WHERE email = ?");
$stmt->bind_param("ss", password_hash($newPassword, PASSWORD_DEFAULT), $email);
if ($stmt->execute()) {
    echo json_encode(['message' => 'パスワードが更新されました。']);
} else {
    echo json_encode(['message' => 'パスワードの更新に失敗しました。']);
}
// 7桁の数字の新しいIDを作成する関数
function generateNumericId($length = 7) {
    $digits = '0123456789';
    $digitsLength = strlen($digits);
    $randomId = '';
    for ($i = 0; $i < $length; $i++) {
        $randomId .= $digits[rand(0, $digitsLength - 1)];
    }
    return $randomId;
}
// 7桁の新しいIDを作成
$newId = generateNumericId(); 
// IDをデータベースに更新
$stmt = $conn->prepare("UPDATE users SET user_id = ? WHERE email = ?");
$stmt->bind_param("ss", $newId, $email);
if (!$stmt->execute()) {
    echo json_encode(['message' => 'IDの再発行に失敗しました。']);
    exit();
}
// メール送信
$subject = "新しいIDの発行";
$message = "あなたの新しいIDは: $newId です。";
$headers = "From: [IDを送信するメールアドレス(例:noreply012@mail.com)]"; // 送信者のメールアドレスを設定
if (mail($email, $subject, $message, $headers)) {
    echo json_encode(['message' => 'パスワードが更新され、新しいIDが発行され、メールが送信されました。']);
} else {
    echo json_encode(['message' => 'メール送信に失敗しました。']);
}
$stmt->close();
$conn->close();
?>

退会ページの作成

最後に追加する機能として、退会ページを作成します。

そこでは、新規登録ページで登録したユーザー情報を削除するための処理を行い、再度、新規登録ページでユーザー登録をしないと、ログインできないようにしたいと思います。

なので、srcディレクトリ内のcomponentsディレクトリ内のpagesディレクトリ内にLeaveディレクトリを作成し、index.tsxファイルを作成します。
(src/components/pages/Leave/index.tsx)

frontend/components/pages/Leave/index.tsx
import React, { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';

const Leave: React.FC = () => {
    const [selectedReason, setSelectedReason] = useState<string>('');
    const [otherReason, setOtherReason] = useState<string>('');
    const [errorOtherReason, setErrorOtherReason] = useState<string | null>(null);
    const navigate = useNavigate();
   
    const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
        event.preventDefault();

        if (selectedReason === 'other' &&  !otherReason.trim()) {
            setErrorOtherReason('理由を入力してください。');
            return;
        } else {
            setErrorOtherReason(null);
        }

        // POSTリクエストで退会理由を送信
        const response = await fetch('http://localhost:8080/leave.php', {
            method: 'POST',
            headers: {
                'Content-Type' : 'application/json',
            },
            body: JSON.stringify({
                selectedReason,
                otherReason:  selectedReason === 'other' ? otherReason : '',
            }),
        });

        if (response.ok) {
            const result = await response.json();
            console.log(result);
            // 退会完了ページにリダイレクト
            navigate('/leave/confirm', {
                state: {
                    selectedReason,
                    otherReason: selectedReason === 'other' ? otherReason: '',
                }
            });

        } else {
            console.error('退会手続きに失敗しました。')
        }
    };

    return (
        <div>
            <h1>退会理由</h1>
            <form onSubmit={onSubmit}>
                <label>
                    <input
                    type="radio"
                    value="no_fun"
                    checked={selectedReason === 'no_fun'}
                    onChange={() => setSelectedReason('no_fun')}/>
                    仕事がつまらないから
                </label>
                <label>
                    <input 
                    type="radio"
                    value="change_job"
                    checked={selectedReason === 'change_job'}
                    onChange={() => setSelectedReason('change_job')}/>
                   転職が決まったから
                </label>
                <label>
                    <input
                    type="radio"
                    value="no_job"
                    checked={selectedReason === 'no_job'}
                    onChange={() => setSelectedReason('no_job')}/>
                    入れる日数が少ないから
                </label>
                <label>
                    <input
                    type="radio"
                    value="relationship"
                    checked={selectedReason === 'relationship'}
                    onChange={() => setSelectedReason('relationship')}/>
                    人間関係が悪いから
                </label>
                <label>
                    <input
                    type="radio"
                    value="other"
                    checked={selectedReason === 'other'}
                    onChange={() => setSelectedReason('other')}/>
                    その他
                </label>
                {selectedReason === 'other' && (
                    <div>
                        <textarea
                        placeholder="その理由を入力してください。"
                        value={otherReason}
                        onChange= {(e) => setOtherReason(e.target.value)}
                        onBlur={() => {
                            if (!otherReason.trim()) {
                                setErrorOtherReason('理由を入力してください。');
                            }
                        }}
                        />
                        {errorOtherReason && <p style={{color: 'red'}}>{errorOtherReason}</p>}
                    </div>
                )}
                
                <button type="submit">確認画面へ</button>
                <div className="cancel">
                <a href="/login">キャンセル</a>        
                </div>       
            </form>
            </div>
    );
};

export default Leave;

また、デザインを整えていきます。

Leaveディレクトリ内に、index.cssファイルを作成します。

frontend/components/pages/Leave/index.css
/* 全体のスタイル */
body {
  font-family: Arial, sans-serif;
  margin: 0;
  padding: 0;
  background-color: #f9f9f9;
}

/* タイトルのスタイル */
h1 {
  margin-bottom: 30px;
  text-align: center;
  margin-top: 50px;
  font-size: 28px;
  font-weight: bold;
}

/* フォームの全体設定 */
form {
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background-color: white;
  border: 1px solid #ddd;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

/* ラジオボタンとテキストの整列 */
label {
  display: flex;
  align-items: center;
  font-size: 16px;
  margin-bottom: 15px;
  width: 100%;
}

/* ラジオボタンの間隔 */
label input[type="radio"] {
  margin-right: 10px;
}

/* テキストエリアのスタイル */
textarea {
  margin: 15px 0;
  width: 500px;
  height: 150px;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 6px;
  box-sizing: border-box;
  font-size: 14px;
}

/* エラーメッセージのスタイル */
p {
  color: red;
  font-size: 14px;
  margin: 5px 0;
}

/* ボタンのスタイル */
button {
  padding: 10px 20px;
  border: none;
  color: white;
  cursor: pointer;
  font-size: 16px;
  font-weight: bold;
  background-color: rgb(53, 133, 246);
  border-radius: 6px;
  transition: background-color 0.3s ease;
}

button:hover {
  background-color: blue;
}

/* キャンセルリンク */
.cancel a {
  display: inline-block;
  margin-top: 15px;
  color: blue;
  text-decoration: none;
  font-size: 14px;
}

.cancel a:hover {
  color: darkblue;
}

退会画面の最初の画面は、下のようになります。

新規メモ.jpeg

退会理由確認画面ページ

Leaveディレクトリ内にConfirm.tsxファイルを作成します。
(src/components/pages/Leave/Confirm.tsx)

frontend/src/components/pages/Leave/Confirm.tsx
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from 'react-query';
import { Me } from '../../../types'

const LeaveConfirm: React.FC = () => {
    const location = useLocation();
    const navigate = useNavigate();
    const queryClient = useQueryClient();
    const { selectedReason, otherReason, userId } :{selectedReason: string; otherReason:string; userId: Me['user_id']} = location.state || {selectedReason: '', otherReason: '', userId: ''};

    const mutation = useMutation(async () => {
        // ここで退会処理を行う
        const response = await fetch('http://localhost:8080/leave.php', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                userId,
                selectedReason,
                otherReason
            })
        });

        if (!response.ok) {
            throw new Error('退会処理に失敗しました。');
        }

        // 特定のキーをlocalStorageから削除
        localStorage.removeItem('user');
        // 全てのキャッシュを削除
        queryClient.clear();
    }, {
        onSuccess: () => {
            // 退会完了後、完了ページに遷移
            navigate('/leave/complete');
        },
        onError: (error: unknown) => {
            if (error instanceof Error) {
            console.error(error.message);
        } else {
            console.error('不明なエラーが発生しました。');
             }
        }
    });

    const handleLeave = () => {
        mutation.mutate();
    };

    return (
        <div className="container">
            <h1>退会理由</h1>
            <p>{selectedReason === 'other' ? otherReason : selectedReason}</p>
            <p>退会すると全ての情報が削除されますがよろしいでしょうか</p>
            <div>
                <button className="leave" onClick={handleLeave} disabled={mutation.isLoading}>
                    {mutation.isLoading ? '処理中...' : '退会する'}
                    </button>
                <button className="cancel" onClick={() => navigate('/leave')}>キャンセル</button>
            </div>
        </div>
    );
};

export default LeaveConfirm;

Leaveディレクトリ内にConfirm.cssを作成します。

そして、確認画面のデザインを整えます。

frontend/src/components/pages/Leave/Confirm.css
/* 全体のスタイル */
body {
  font-family: Arial, sans-serif;
  margin: 0;
  padding: 0;
  background-color: #f9f9f9;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 70vh; /* 縦方向の中央揃え */
}

/* タイトルのスタイル */
h1 {
  margin-bottom: 30px;
  text-align: center;
  margin-top: 50px;
  font-size: 28px;
  font-weight: bold;
  text-align: center;
}

/* フォームの全体設定 */
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 800px;
  width: 90%;
  margin: 0 auto;
  padding: 20px;
  background-color: white;
  border: 1px solid #ddd;
  border-radius: 8px;
  box-sizing: border-box;
  font-size: 16px;
  resize: none; /* サイズ変更を無効化 */
}

/* エラーメッセージのスタイル */
p {
  width: 600px;
  text-align: center;
  color: red;
  font-size: 14px;
  margin: 10px 0;
}

/* ボタンのスタイル */
button.leave {
  width: 150px; 
  padding: 10px;
  margin: 10px 5px; /* ボタンの余白を調整 */
  border: none;
  color: white;
  cursor: pointer;
  font-size: 16px;
  font-weight: bold;
  background-color: red;
  border-radius: 6px;
  transition: background-color 0.3s ease;
}

button.leave:hover {
  background-color: darkred;
}

/* キャンセルリンク */
button.cancel  {
  width: 150px; 
  padding: 10px;
  margin: 10px 5px; /* ボタンの余白を調整 */
  border: none;
  color: white;
  cursor: pointer;
  font-size: 16px;
  font-weight: bold;
  background-color: rgb(167, 160, 160);
  border-radius: 6px;
  transition: background-color 0.3s ease;
}

button.cancel:hover {
  color: gray;
}

PHPとの連携が出来ていれば、退会理由の下に前ページで選んだ理由の内容が表示されるようになっていますが、

今は、なされていないので、下のようなデザインになっていればOKです。

新規メモ.jpeg

次は、バックエンドのディレクトリであるphpディレクトリ内にleave.phpファイルを作成します。

データベースに登録されたユーザー情報を削除するための処理

php/leave.php
<?php
header('Content-Type: application/json');
$host = 'mysql';
$dbname = 'kintai';
$user = 'user';
$password = 'password';
try {
    // データベース接続
    $pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8", $user, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    // テーブルが存在しない場合は作成
    $createTableSQL = "
    CREATE TABLE IF NOT EXISTS leave_reasons (
        id INT AUTO_INCREMENT PRIMARY KEY,
        user_id INT NOT NULL,
        reason VARCHAR(255) NOT NULL,
        other_reason TEXT,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )";
    $pdo->exec($createTableSQL);
    // リクエストからデータを取得
    $input = json_decode(file_get_contents('php://input'), true);
    $userId = $input['userId'] ? (int)$input['userId'] : null; //userIdを整数に変換
    $selectedReason = $input['selectedReason'] ?? '';
    $otherReason = $input['otherReason'] ?? '';
    if ($userId === null) {
        echo json_encode(['status' => 'error', 'message' => 'ユーザーIDが無効です。']);
        exit;
    }
    // データをデータベースに登録
    $stmt = $pdo->prepare("INSERT INTO leave_reasons (user_id, reason, other_reason) VALUES (:user_id, :reason, :other_reason)");
    $stmt->bindParam(':user_id', $userId, PDO::PARAM_INT);
    $stmt->bindParam(':reason', $selectedReason);
    $stmt->bindParam(':other_reason', $otherReason);
    $stmt->execute();
    // ユーザー情報を削除する
    $deleteUserStmt = $pdo->prepare("DELETE FROM users WHERE user_id = :user_id");
    $deleteUserStmt->bindParam(':user_id', $userId, PDO::PARAM_INT);
    $deleteUserStmt->execute();
    // 成功レスポンスを返す
    echo json_encode(['status' => 'success', 'message' => '退会理由が登録され、ユーザーが削除されました。']);
} catch (PDOException $e) {
    // エラーレスポンスを返す
    echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
}
?>

再度、フロントエンド側に戻り、Leaveディレクトリ内に、退会手続きが完了したことを告げるページ
Complete.tsxファイルを作成します。

(src/components/pages/Leave/Complete.tsx)

frontend/src/components/pages/Leave/Complete.tsx

そして、退会完了画面のデザインを作成するために、Leaveディレクトリ内にComplete.cssファイルを作成します。

frontend/src/components/pages/Leave/Complete.c
/* 全体のスタイル */
body {
  font-family: Arial, sans-serif;
  margin: 0;
  padding: 0;
  background-color: #f9f9f9;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 70vh; /* 縦方向の中央揃え */
}

/* タイトルのスタイル */
h1 {
  margin-bottom: 30px;
  text-align: center;
  margin-top: 50px;
  font-size: 28px;
  font-weight: bold;
  text-align: center;
}

/* フォームの全体設定 */
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: 800px;
  width: 90%;
  margin: 0 auto ;
  padding: 20px;
  background-color: white;
  border: 1px solid #ddd;
  border-radius: 8px;
  box-sizing: border-box;
  font-size: 16px;
  resize: none; /* サイズ変更を無効化 */
}

.container > div:last-child {
  margin-top: 38px; /* ボタン部分全体を下げる */
  margin-bottom: 38px; /* ボタンと下の枠線との間隔を38px空ける */
}

/* エラーメッセージのスタイル */
p {
  width: 600px;
  text-align: center;
  color: red;
  font-size: 16px;
  margin: 10px 0 10px; /* 下方向に30pxの余白を追加 */
}

/* キャンセルリンク */
.a, a {
  color: white;
  font-size: 14px;
  text-decoration: none; /* 下線を消す */
  width: 150px; 
  padding: 10px;
  margin: 10px 5px; /* ボタンの余白を調整 */
  border: none;
  cursor: pointer;
  font-size: 16px;
  font-weight: bold;
  background-color: rgb(167, 160, 160);
  border-radius: 6px;
  transition: background-color 0.3s ease;
}

.a, a:hover {
  background-color: gray;
}

完了画面は、こうなります。

新規メモ.jpeg

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

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

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

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

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

frontend/src/components/hooks/useAuth.ts
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from 'react-query';
import { Me } from '../../types';

// APIを呼び出すための関数
const loginUser = async ({ userId, password}:{userId: string, password: string}) => {
    const response = await fetch('http://localhost:8080/auth.php', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: new URLSearchParams({userId, password}).toString(),
    });

    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 [loading, setLoading] = useState<boolean>(false); // ローディング状態を保持
    const [errorMessage, setErrorMessage] = useState<string | null>(null); //エラーメッセージの保持
    const navigate = useNavigate();

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

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

useEffect(() => {
    if (mutation.isError) {
        setErrorMessage('ログインに失敗しました。');
    }

    if (user === null ) {
        navigate('/');
    }
},[user, mutation.isError, navigate]);

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

export default useAuth;

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

frontend/src/pages/leave/index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Leave from '../../components/pages/Leave';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user}/>
      <Leave/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();
frontend/src/pages/leave/confirm.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import LeaveConfirm from '../../components/pages/Leave/Confirm';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user}/>
      <LeaveConfirm/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();
frontend/src/pages/leave/complete.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import LeaveComplete from '../../components/pages/Leave/Complete';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user}/>
      <LeaveComplete/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();
frontend/src/pages/mypage/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Mypage from '../../components/pages/Mypage';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user}/>
      <Mypage/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();
frontend/src/pages/register/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Register from '../../components/pages/Register';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user}/>
      <Register/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();
frontend/src/pages/report/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import Report from '../../components/pages/Report';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';

// 作業日報送信の失敗処理
const handleSubmissionFail = () => {
  console.log('作業日報の送信に失敗しました。')
}


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user} />
      <Report onSubmissionFail={handleSubmissionFail}/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();
frontend/src/pages/reset_password/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import ResetPassword from '../../components/pages/ResetPassword';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user}/>
      <ResetPassword/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();
frontend/src/pages/time_tracking/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import TimeTracking from '../../components/pages/TimeTracking';
import reportWebVitals from '../../reportWebVitals';
import Globalnav from '../../components/shared/Globalnav';
import useAuth from '../../components/hooks/useAuth';


const Index: React.FC = () => {
  const { user, loading} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

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

  return (
    <React.StrictMode>
      <Globalnav user={user}/>
      <TimeTracking/>
    </React.StrictMode>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();

Reactのを動かした際、エントリーポイントとなるファイルは、frontend/src/index.tsxです。

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

QueryClient の作成

const queryClient = new QueryClient(); 

で QueryClient をインスタンス化します。

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

QueryClientProvide でラップ

QueryClientProvider コンポーネントを使って、client プロパティに queryClient を渡し、App をその中でラップします。

これにより、react-query の機能がアプリケーション全体で利用できるようになります。

そして、ブラウザを動かして、最初に表示されるページは、ログインページにしたいので、

frontend/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from '../src/components/pages/App';
import reportWebVitals from '../src/reportWebVitals';
import Globalnav from '../src/components/shared/Globalnav';
import useAuth from '../src/components/hooks/useAuth';
import {QueryClient, QueryClientProvider} from 'react-query';
import { BrowserRouter } from 'react-router-dom';

// QueryClientインスタンスを作成
const queryClient = new QueryClient();


const Index: React.FC = () => {
  const { user, loading, errorMessage} = useAuth();

  if (loading) {
    return <div>処理中...</div>
  }

  if (errorMessage) {
    return <div>{errorMessage}</div>
  }

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

  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
      <Globalnav user={user}/>
      <App/>
      </BrowserRouter>
    </QueryClientProvider>    
  );
};

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<Index/>)

reportWebVitals();

という内容にします。

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

Dockerの起動

$ docker-compose up -d –build

そして、ブラウザで、http://localhost:3000

と入力して動かしてみるのですが、

新規メモ.jpeg

画像のように、ページは何も表示されず、

react-dom.production.min.js:188 Error
    at le (useAuth.ts:32:22)
    at ue (index.tsx:16:42)
co	@	react-dom.production.min.js:188

react-dom.production.min.js:282 Uncaught Error
    at le (useAuth.ts:32:22)
    at ue (index.tsx:16:42)
    

というエラーが発生します。

ただいま、調査中です🫡

もし、このエラーの解決方法が分かる方、もっとこうした方がいいと思う方がいれば、

コメントの方よろしくお願いします。

0
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?