環境構築編では、バックエンドのLaravelをインストールし、フロントエンドでは、Next.jsをインストールしました。
バックエンドとフロントエンドの通信構築(API)が苦手なので、
まずは、フロントエンドのサイトの画面のデザインの実装からしていきます。
まず、最初に、以下が、作成しようとしている勤怠管理アプリの機能です。
まず、新規登録・ログインの機能があり、ログインしたユーザーのみがトップページに進める
▼ログインページ
タイトル : ログイン
必要カラム:「パスワード」「ユーザー名」
ボタン :
「ログイン」→ トップ画面
「新規登録はこちら」→新規登録ページ
「パスワードを忘れた方はこちらへ」 → パスワード再設定画面
▼新規登録ページ
ユーザー名
メールアドレス
パスワード
プロフィールアイコン
生年月日
性別
▼パスワード再設定ページ
必要カラム: 「登録済みメールアドレス」
ボタン: 「パスワードを再設定」→ 登録したメールアドレスに再設定するためのメールが届く
ログインが成功すると、
▼トップ画面(新たに作る)
〇〇〇〇年/
画面の真ん中に現在の時刻(◯(時):◯(分):◯(秒))という形で表示されるようにする。
下に各ボタンを配置
ボタン ※各ボタンを押すとデータベースに記録されるようする。
「出勤」→ 「お仕事頑張ってください」というメッセージが出る
「退勤」→ 「お仕事お疲れ様でした」というメッセージが出る→ 出勤時間、退勤時間、休憩開始時間、休憩終了時間、勤務時間数、休憩時間数がレコードで表示される。
「休憩開始」→ 「休憩を開始しました。」というメッセージが出る。
「休憩終了」→「休憩を終了しました。」というメッセージが出る。
「作業日報」→作成した「作業日報」ページへ
タイトル「掲示板」→「作業日報」 に変更
「掲示板一覧」→ 「日報一覧」に変更
「投稿する」ボタンを「提出する」に変更
ディレクトリ構造の変更
フロントエンドであるfrontendディレクトリを以下のような構造にします。
attendance
├── laravel
└── frontend
│ │ ├── app
│ │ ├── 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
│ │ │ └── types └── index.tsx
│ │ │ └── index.ts
環境変数の使用
Next.jsのプロジェクトのルートディレクトリ(package.json が存在する場所)に移動します。
.env.local という名前のファイルを作成します。
API URL を環境変数で管理するため、.env.local ファイルに以下を追加します
NEXT_PUBLIC_API_URL=http://localhost:8080
.env.local の変更を反映するには、Next.js のサーバーを再起動する必要があります。
$ npm run dev
1.ログインページ
import React, {useState} from 'react';
import { useRouter } from 'next/router';
import { useQueryClient, useMutation } from 'react-query';
const App: React.FC = () => {
const [userId, setUserId] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const router = useRouter();
const queryClient = useQueryClient();
const mutation = useMutation(async ({userId, password}:{userId: string; password: string}) => {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json', // JSON形式を指定
'Accept': 'application/json', // Laravelで一般的
},
body: JSON.stringify({ userId, password}), // JSON形式でデータ送信
});
if (!response.ok) throw new Error('ログインできませんでした。');
return await response.json();
},{
onSuccess: (data) => {
if (data.success) {
// 成功時の処理
setMessage('ログイン成功');
//全てのキャッシュを削除
queryClient.clear();
router.push('/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;
そして、デザインを整えます。
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;
}
すると、下のような画面になると思います。
Laravel 側の準備
1. APIルートの設定
laravelの**routesディレクトリ内に、api.phpファイルを作成し、以下を記述します。
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
// CORSミドルウェアを利用してAPI全体でCORS対応
Route::middleware('cors')->group(function () {
// ユーザー認証のためのPOSTエンドポイント
Route::post('/login', function (Request $request) {
// CORSに対応するためOPTIONSメソッドに対して200を返す
if ($request->isMethod('options')) {
return response('', 200);
}
// リクエストから必要なデータを取得
$userId = $request->input('userId');
$password = $request->input('password');
// ユーザー情報をデータベースから取得
$user = DB::table('users')->where('user_id', $userId)->first();
if (!$user) {
return response()->json(['success' => false, 'message' => 'ユーザーが見つかりません。'], 404);
}
// パスワードを確認
if (Hash::check($password, $user->password)) {
// パスワードが正しい場合、パスワードを除外してユーザー情報を返す
unset($user->password);
return response()->json(['success' => true, 'user' => $user]);
} else {
return response()->json(['success' => false, 'message' => 'パスワードが正しくありません。'], 401);
}
});
});
2. CORS設定
フロントエンドが別ドメイン(例: http://localhost:3000)で動作している場合、CORSを許可する必要があります。
laravel/config/cors.phpを編集して、http://localhost:3000を許可します。
デフォルトのLaravelプロジェクトにはconfig/cors.phpファイルが含まれています。このファイルを開いて、http://localhost:3000を許可します。
config/cors.phpファイルが存在しない場合、CORS設定用のパッケージがインストールされていない可能性があります。以下の手順で確認・インストールを行います。
1.1 パッケージの確認
fruitcake/laravel-corsはLaravelにCORS機能を提供します。このパッケージがインストールされているか確認します。
$ composer show fruitcake/laravel-cors
出力例:
name : fruitcake/laravel-cors
descrip. : Adds CORS (Cross-Origin Resource Sharing) headers support in your Laravel application
version : v3.0.0
インストール
インストールされていない場合は、以下のコマンドを実行してインストールします。
$ composer require fruitcake/laravel-cors
2. cors.phpファイルの作成
パッケージがインストールされたら、以下のコマンドを実行してconfig/cors.phpを生成します。
$ php artisan vendor:publish --tag="cors"
このコマンドを実行すると、config/cors.phpファイルが生成されます。
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'], // APIリクエストを許可するパス
'allowed_methods' => ['*'], // 許可するHTTPメソッド(例: GET, POSTなど)
'allowed_origins' => ['http://localhost:3000'], // 許可するオリジン
'allowed_origins_patterns' => [], // 特定のパターンで許可する場合
'allowed_headers' => ['*'], // 許可するヘッダー
'exposed_headers' => [], // クライアント側でアクセス可能なヘッダー
'max_age' => 0, // プリフライトリクエストのキャッシュ時間
'supports_credentials' => true, // クッキー情報を許可する場合
];
Laravel (※Laravel11以外)では、CORS設定を管理するためのファイルを自分で作成することができます。上の手順で作成してください。
そして、
laravel/app/Http/Kernel.php にミドルウェアを追加
cors.php 設定を有効にするために、app/Http/Kernel.php ファイルを編集し、以下のミドルウェアを追加してください。
$middleware 配列に次のコードを追加します:
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* This value is typically used by your application to define the
* middleware that should be run during every HTTP request.
*
* @var array
*/
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
+ \Illuminate\Http\Middleware\HandleCors::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\Illuminate\Http\Middleware\LoadAverage::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\Authenticate::class,
];
/**
* The application's route middleware groups.
*
* These middleware may be assigned to groups for easy usage.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to individual routes.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
];
}
これにより、Laravel(※Laravel11以外)の標準機能でCORSが有効化されます。
重要ポイント:
allowed_origins: http://localhost:3000のようにフロントエンドのオリジンを指定します。
すべてのオリジンを許可する場合: ['*'] と設定。
supports_credentials: フロントエンドで認証付きリクエストを行う場合(例: Cookieやセッションを使用)、trueに設定します。
CORS設定をリフレッシュ
設定を変更した後は、キャッシュをクリアして設定を反映します。
$ php artisan config:clear
$ php artisan config:cache
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オブジェクトに追加し、アイコンファイルを送信。
これでアイコンを選択して登録できるようになります。サーバー側でもアイコンの処理を適切に行う必要がありますので、その点も考慮してください。
これらを踏まえてのコードは以下になります。
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(`${process.env.NEXT_PUBLIC_API_URL}/api/register`,{
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ファイルを作成し、以下を記述します。
/* フォーム全体のスタイル */
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;
}
すると、
といった画面が作成されれば、OKです。
送信されたフォームデータをLaravelを使用して、データベースに登録するには、以下の手順を実行します。
1. マイグレーションファイルを作成
データベースに新しいテーブルを作成するためのマイグレーションファイルを作成します。
ターミナルで以下のコマンドを実行します
$ php artisan make:migration create_users_table --table=users
を実行すると、
databse/migrationsディレクトリ内にXXXX_XX_XX_XXXXX_create_users_table.phpといったファイルが作成されます。
そこに、以下のコードを追加します。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
+ //すでに存在する場合は、テーブルを作成しない
+ if (!Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table) {
+ $table->id();
+ $table->string('username');
+ $table->string('email')->unique();
+ $table->string('password');
+ $table->rememberToken(); // リメンバートークン
+ $table->date('birthdate');
+ $table->enum('gender', ['male', 'female', 'other']);
+ $table->string('icon')->nullable(); // アップロードされたアイコンのパス
+ $table->timestamps();
});
}
+ }
public function down(): void
{
- Schema::table('users', function (Blueprint $table)
+ Schema::dropIfExists('users');
}
};
マイグレーションを実行してテーブルを作成します:
$ php artisan migrate
マイグレーションファイルを修正した場合
以下のコマンドでマイグレーションをリセットして再実行します。
# データベースのマイグレーションをリセット
$ php artisan migrate:reset
# 再びマイグレーションを実行
$ php artisan migrate
2. モデルを作成
User モデルを作成または更新します。
Laravelにはデフォルトで User モデルがあります。app/Models/User.php を以下のように編集します:
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
+ 'birthdate',
+ 'gender',
+ 'icon',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
3. コントローラを作成
新規ユーザーを登録する処理を行うコントローラを作成します。
ターミナルで以下を実行
$ php artisan make:controller UserController
すると、app/Http/Controllersディレクトリ内に、UserController.phpファイルが作成されると思います。
生成されたUserController.phpを以下のように編集します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UserController extends Controller
{
public function register(Request $request)
{
// バリデーション
$validator = Validator::make($request->all(), [
'username' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:7|max:8|regex:/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/',
'birthdate' => 'required|date',
'gender' => 'required|in:male,female,other',
'icon' => 'nullable|file|image|max:2048',
]);
// バリデーションエラー時のレスポンス
if ($validator->fails()) {
return response()->json([
'message' => '入力データにエラーがあります。',
'errors' => $validator->errors(),
], 422);
}
// 7桁の数字IDを生成
$userId = $this->generateNumericId();
// アイコンの保存
$iconPath = null;
if ($request->hasFile('icon')) {
$file = $request->file('icon');
$fileName = Str::uuid() . '.' . $file->getClientOriginalExtension();
$iconPath = $file->storeAs('public/images/icons', $fileName);
}
// ユーザー作成
$user = User::create([
'user_id' => $userId,
'username' => $request->username,
'email' => $request->email,
'password' => Hash::make($request->password),
'birthdate' => $request->birthdate,
'gender' => $request->gender,
'icon' => $iconPath ? Storage::url($iconPath) : null,
]);
// メール送信
try {
$this->sendRegistrationEmail($user);
} catch (\Exception $e) {
\Log::error("メール送信エラー: " . $e->getMessage());
return response()->json([
'message' => '登録は成功しましたが、メール送信に失敗しました。',
'error' => $e->getMessage(),
], 500);
}
return response()->json([
'message' => '登録が完了しました。メールを確認してください。',
'user_id' => $user->user_id,
'username' => $user->username,
'iconpath' => $user->icon,
], 201);
}
public function listUsers()
{
$users = User::select('user_id', 'username')->get();
return response()->json(['users' => $users]);
}
private function generateNumericId($length = 7)
{
do {
$id = str_pad(mt_rand(0, 9999999), $length, '0', STR_PAD_LEFT);
} while (User::where('user_id', $id)->exists());
return $id;
}
private function sendRegistrationEmail($user)
{
$subject = "登録完了のお知らせ";
$message = "ID番号: {$user->user_id}\nIDは大切に保管してください。";
try {
Mail::raw($message, function ($mail) use ($user, $subject) {
$mail->to($user->email)
->subject($subject)
->from(config('mail.from.address'), config('mail.from.name'));
});
} catch (\Exception $e) {
\Log::error("メール送信エラー: " . $e->getMessage());
throw $e;
}
}
}
4. ルートを定義
routes/api.php に以下のルートを追加します
use App\Http\Controllers\UserController;
Route::post('/register', [UserController::class, 'register']);
Route::get('/users', [UserController::class, 'listUsers']);
5. メール設定
config/mail.phpでメール設定を行います。
例えば、Gmailを使用する場合:
return [
'default' => env('MAIL_MAILER', 'smtp'),
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST', 'smtp.gmail.com'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
],
],
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'xxxxxxxx@xxxx.xxx(自身のメールアドレス)'),
'name' => env('MAIL_FROM_NAME', '${APP_NAME}'),
],
];
というふうに変えます。
そして、
.envファイルにあるメール設定も変更します。
(gmailの場合)
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=XXXXXXXX@XXXX.XXX(自身のメールアドレス)
MAIL_PASSWORD=********(自身のパスワード)
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="XXXXXXXX@XXXX.XXX"(自身のメールアドレス)
MAIL_FROM_NAME="${APP_NAME}"
6. ストレージの公開
アップロードされた画像を公開するために、以下を実行します
$ php artisan storage:link
これで、アップロードされたアイコンは /storage/icons パスでアクセス可能になります。
7. フロントエンドからリクエストを送信
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/register`, {
method: 'POST',
body: formData,
});
の部分が、Laravelがフォームデータを受け取り、データベースに登録し、成功レスポンスを返すようになります。
登録した情報は、他のファイルで結構使うので、インポートするために、srcディレクトリ内にtypesというディレクトリを作成し、tsファイルを作成します。
export type Me = {
user_id: string; // 7桁のユーザーID
username: string; // ユーザー名
email: string; //メールアドレス
birthdate: string; // 生年月日
gender: string; // 性別
icon?: string; // アイコンのパス
}
3.パスワード再設定ページ
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(`${process.env.NEXT_PUBLIC_API_URL}/api/reset_password`, {
method: 'POST',
headers: {'Content-Type' : 'application/json'},
body: JSON.stringify({ email, new_password: newPassword, confirm_password: confirmPassword}),
});
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();
};
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ファイルを作成し、以下を記述します
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です。
1. コントローラーの作成
作成したパスワード再設定ページで入力された情報をデータベースに反映させるために、
laravelプロジェクトの中に、ResetPasswordControllerを作成します。
laravelディレクトリ内で、
$ php artisan make:controller ResetPasswordController
とコマンドを打つと、
app/Http/Controllers内に、ResetPasswordController.phpファイルができるので、
以下のコードを記述します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
class ResetPasswordController extends Controller
{
public function resetPassword(Request $request)
{
// バリデーション
$validator = Validator::make($request->all(), [
'email' => 'required|email|exists:users,email',
'new_password' => [
'required',
'string',
'min:7',
'max:8',
'regex:/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/',
],
'confirm_password' => 'required|same:new_password',
]);
// バリデーションエラー時のレスポンス
if ($validator->fails()) {
return response()->json([
'message' => '入力データにエラーがあります。',
'errors' => $validator->errors(),
], 422);
}
// ユーザーの取得
$user = User::where('email', $request->email)->first();
// 新しいパスワードをハッシュ化して保存
$user->password = Hash::make($request->new_password);
// 新しい7桁のIDを生成
$user->user_id = $this->generateNumericId();
// データ保存
if (!$user->save()) {
return response()->json(['message' => 'パスワードの更新に失敗しました。'], 500);
}
// メール送信
try {
$this->sendResetEmail($user);
} catch (\Exception $e) {
\Log::error("メール送信エラー: " . $e->getMessage());
return response()->json([
'message' => 'パスワードは更新されましたが、メール送信に失敗しました。',
'error' => $e->getMessage(),
], 500);
}
return response()->json([
'message' => 'パスワードが更新され、新しいIDが発行され、メールが送信されました。',
'user_id' => $user->user_id,
], 200);
}
private function generateNumericId($length = 7)
{
do {
$id = str_pad(mt_rand(0, 9999999), $length, '0', STR_PAD_LEFT);
} while (User::where('user_id', $id)->exists());
return $id;
}
private function sendResetEmail($user)
{
$subject = "パスワードリセットのお知らせ";
$message = "ID番号: {$user->user_id}\nIDは大切に保管してください。";
try {
Mail::raw($message, function ($mail) use ($user, $subject) {
$mail->to($user->email)
->subject($subject)
->from(config('mail.from.address'), config('mail.from.name'));
});
} catch (\Exception $e) {
\Log::error("メール送信エラー: " . $e->getMessage());
throw $e;
}
}
}
また、routes/api.phpにエンドポイントを追加します。
use App\Http\Controllers\ResetPasswordController;
Route::post('/reset-password', [ResetPasswordController::class, 'resetPassword']);
2.ユーザーモデルの準備
app/Models/User.phpにuser_idフィールドを追加できるように、
モデルの$fillableプロパティを更新します。
protected $fillable = [
+ 'user_id',
'name',
'email',
'password',
];
Laravelのマイグレーションを利用して、usersテーブルにuser_idを追加します。
$ php artisan make:migration add_user_id_to_users_table --table=users
というコマンドを打つと、database/migrationsディレクトリ内に、
xxxx_xx_xx_xxxxxx_add_user_id_to_users_table.phpファイルが作成されます。
そのファイルに以下のコードを追加します。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
+ $table->string('user_id', 7)->unique()->after('id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('user_id');
});
}
};
そして、再度、マイグレーションを実行します。
$ php artisan migrate
4.勤怠管理ページ
「出勤」や「退勤」などのボタンを押した際、トースト通知が出るようにするために、reactーtoastifyを使用するので、
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"
]
}
以上を追記します。
import React, {useEffect, useState } from 'react';
import { useRouter } from 'next/router';
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 router = useRouter();
// 時刻が更新する関数
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(`${process.env.NEXT_PUBLIC_API_URL}/api/time_tracking`, {
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);
router.push('/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;
}
router.push('/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;
デザインの調整
.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;
}
すると、こんな画面になります。
上部に現在の時間が秒単位で更新される形で表示され、
下の方に勤怠ボタンが横一列に並ぶ形になればOKです。
Laravelでは、フロントエンドで登録された情報をデータベースに反映させるために、コントローラー、ルート、モデル、マイグレーションを作成する必要があります。
1. マイグレーションとモデルの作成
attendanceとreportsのデータを扱うマイグレーションとモデルを作成します。
マイグレーション
以下のコマンドでマイグレーションファイルを作成します。
$ php artisan make:migration create_attendance_table
すると、database/migrationsディレクトリ内に
XXXX_XX_XX_XXXXX_create_attendance_table.phpと
いうファイルが作成されます。
作成されたこのマイグレーションファイルにテーブル構造を記述します。
public function up()
{
Schema::create('attendance', function (Blueprint $table) {
$table->id();
+ $table->unsignedBigInteger('user_id');
+ $table->string('type'); // 'in', 'out', 'breakStart', 'breakEnd'
+ $table->timestamp('time');
$table->timestamps();
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
その後、以下のコマンドでマイグレーションを実行してテーブルを作成します。
$ php artisan migrate
モデルの作成
Attendanceのモデルを生成します。
$ php artisan make:model Attendance
すると、app/Modelsディレクトリ内に、
Attendance.phpが作成されると思います。
そこのモデルに以下を追加します。
class Attendance extends Model
{
+ protected $fillable = ['user_id', 'type', 'time'];
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
}
2. コントローラーの作成
次は、コントローラーを作成します。
$ php artisan make:controller TimeTrackingController
app/Http/Controllersディレクトリ内に、
TimeTrackingController.phpというファイルが作成されます。
そこに以下を記述します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Attendance;
use App\Models\Report;
use Illuminate\Support\Facades\Auth;
class TimeTrackingController extends Controller
{
public function storeAttendance(Request $request)
{
$userId = Auth::id();
$type = $request->input('type');
$currentTime = now();
// 同じ日に複数の出勤を防ぐ処理
if ($type === 'in' && Attendance::where('user_id', $userId)->whereDate('time', now())->where('type', 'in')->exists()) {
return response()->json(['message' => '出勤は一日一回です。'], 400);
}
Attendance::create([
'user_id' => $userId,
'type' => $type,
'time' => $currentTime,
]);
return response()->json(['message' => '登録が完了しました。']);
}
public function storeReport(Request $request)
{
$userId = Auth::id();
$title = $request->input('title');
$content = $request->input('content');
Report::create([
'user_id' => $userId,
'title' => $title,
'content' => $content,
]);
return response()->json(['message' => '登録が完了しました。']);
}
public function getAttendance(Request $request)
{
$userId = Auth::id();
$year = $request->input('year', now()->year);
$month = $request->input('month', now()->month);
$attendance = Attendance::where('user_id', $userId)
->whereYear('time', $year)
->whereMonth('time', $month)
->orderBy('time', 'desc')
->paginate(31);
return response()->json($attendance);
}
}
3. ルートの作成
先ほど、この書いたコードのAPIルートを作成するので、
routes/api.phpファイルに、以下を追加します。
use App\Http\Controllers\TimeTrackingController;
Route::middleware('auth:sanctum')->group(function () {
Route::post('/time_tracking', [TimeTrackingController::class, 'storeAttendance']);
Route::post('/report', [TimeTrackingController::class, 'storeReport']);
Route::get('/attendance', [TimeTrackingController::class, 'getAttendance']);
});
5.マイページ
ユーザーそれぞれ専用の勤怠実績を表示するためのページを作成します。
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;
デザインの調整
.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との連携ができていないので、マイページは、
ここまででしか表示できていません。
Laravel側での処理
database/migrations/{timestamp}_create_attendances_table.phpを編集して、テーブル構造を定義します。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attendances', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id'); // ユーザーID
- $table->string('type'); // 'in', 'out', 'breakStart', 'breakEnd'
+ $table->string('username'); // ユーザー名
+ $table->string('title')->nullable(); // 作業タイトル
+ $table->text('content')->nullable(); // 作業内容
+ $table->time('in')->nullable(); // 出勤時間
+ $table->time('')->nullable(); // 退勤時間
+ $table->integer('actual_work_hours')->default(0); // 実働時間(時間単位)
+ $table->integer('remaining_actual_work_minutes')->default(0); // 実働時間(分単位)
+ $table->integer('total_break_hours')->default(0); // 休憩時間(時間単位)
+ $table->integer('remaining_break_minutes')->default(0); // 休憩時間(分単位)
- $table->timestamps();
$table->timestamps();
// 外部キー制約
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attendances');
}
};
マイグレーションを実行します。
$ php artisan migrate
Attendanceモデルを以下のように修正して、データベースとのやり取りを定義します。
<?php
namespace App\Models;
+ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Attendance extends Model
{
+ use HasFactory;
+ protected $table = 'attendances';
// 一括代入可能な属性
protected $fillable = [
'user_id',
- 'type',
- 'time',
+ 'username',
+ 'title',
+ 'content',
+ 'in',
+ 'out',
+ 'actual_work_hours',
+ 'remaining_actual_work_minutes',
+ 'total_break_hours',
+ 'remaining_break_minutes',
];
// ユーザーとのリレーション
public function user()
{
return $this->belongsTo(User::class);
}
}
2. コントローラの作成
AttendanceControllerを作成します。
$ php artisan make:controller AttendanceController
app/Http/Controllersディレクトリ内に作成されたAttendanceController.phpに以下のコードを追加します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Attendance;
class AttendanceController extends Controller
{
// 勤怠データの取得
public function index(Request $request)
{
$year = $request->query('year');
$month = $request->query('month');
$page = $request->query('page', 1);
$perPage = 10;
$attendanceQuery = Attendance::query();
if ($year && $month) {
$attendanceQuery->whereYear('created_at', $year)
->whereMonth('created_at', $month);
}
$attendances = $attendanceQuery->paginate($perPage, ['*'], 'page', $page);
$summary = [
'totalBreakHours' => $attendances->sum('total_break_hours'),
'remainingBreakMinutes' => $attendances->sum('remaining_break_minutes'),
'actualWorkHours' => $attendances->sum('actual_work_hours'),
'remainingActualWorkMinutes' => $attendances->sum('remaining_actual_work_minutes'),
];
return response()->json([
'attendance' => $attendances->items(),
'summary' => $summary,
'totalPages' => $attendances->lastPage(),
]);
}
// 勤怠データの保存
public function store(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|exists:users,id',
'username' => 'required|string|max:255',
'title' => 'nullable|string|max:255',
'content' => 'nullable|string',
'in' => 'nullable|date_format:H:i',
'out' => 'nullable|date_format:H:i',
'actual_work_hours' => 'nullable|integer',
'remaining_actual_work_minutes' => 'nullable|integer',
'total_break_hours' => 'nullable|integer',
'remaining_break_minutes' => 'nullable|integer',
]);
$attendance = Attendance::create($validated);
return response()->json([
'message' => '勤怠データを保存しました。',
'attendance' => $attendance,
]);
}
}
6.作業日報ページ
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(`${process.env.NEXT_PUBLIC_API_URL}/api/time_tracking`, {
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ファイルを作成します。
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;
}
すると、作業日報ページは、
のようになります。
マイグレーションファイル
Reactコンポーネントで送信されるデータを格納するためのデータベーステーブルを作成します。たとえば、作業日報を保存するreportsテーブルを用意します。
$ php artisan make:migration create_reports_table
database/migrations/ディレクトリ内に
XXXX_XX_XX_XXXXX_create_reports_table.phpというファイルが作成されます。
そこに以下を記述します。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reports', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id'); // ユーザーID
$table->string('author_name'); // 作業者名
$table->string('task'); // 作業内容
$table->text('content'); // 作業内容詳細
$table->timestamps();
// 外部キー制約(必要に応じて削除可)
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reports');
}
};
2. モデルファイル
Reportのモデルを作成します。
$ php artisan make:model Report
すると、app/Modelsディレクトリ内に、Report.phpが生成されます。
そこに以下を記述します。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Report extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'author_name',
'task',
'content',
];
// ユーザーとのリレーション
public function user()
{
return $this->belongsTo(User::class);
}
}
3. コントローラ
次は、コントローラーを作成します。
$ php artisan make:controller ReportController
app/Http/Controllersディレクトリ内に、
ReportController.phpというファイルが作成されます。
このコントローラーでは、データの保存処理を行います。
そこに以下を記述します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Report;
class ReportController extends Controller
{
/**
* 日報を保存する。
*/
public function store(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|exists:users,id',
'author_name' => 'required|string|max:255',
'task' => 'required|string|max:255',
'content' => 'required|string',
]);
$report = Report::create($validated);
return response()->json([
'message' => '作業日報を送信しました。',
'report' => $report,
], 201);
}
}
4. ルート設定
フロントエンドがリクエストを送信するエンドポイントを設定します。
マイページと作業日報ページを作成したので、
routes/api.phpファイルに以下を追加します。
use App\Http\Controllers\AttendanceController;
Route::get('/time_tracking', [AttendanceController::class, 'index']);
Route::post('/time_tracking', [AttendanceController::class, 'store']);
5. 認証の設定
ユーザーIDの取得に認証が必要です。Laravel Sanctumを利用するのがおすすめです。
$ composer require laravel/sanctum
$ php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
$ php artisan migrate
app/Http/Kernel.phpに以下を追加します。
protected $middlewareGroups = [
'api' => [
+ \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
以上の設定で、終わりです。
4. テストデータの作成
必要に応じて、シーダーやファクトリを使用してデータを生成します。
php artisan make:factory AttendanceFactory --model=Attendance
すると、database/factoriesディレクトリにAttendanceFactory.phpというファイルが作成されます。
ヘッダーメニューとして、グローバルナビゲーションの作成します。
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;
ログインできていない状態だと、下のような画像↓
そして、ログインできたら、「退会する」ボタンや「マイページ」へ繋がるように
アイコンのボタンを配置するように変わるようなデザインにします。
退会ページの作成
最後に追加する機能として、退会ページを作成します。
そこでは、新規登録ページで登録したユーザー情報を削除するための処理を行い、再度、新規登録ページでユーザー登録をしないと、ログインできないようにしたいと思います。
なので、srcディレクトリ内のcomponentsディレクトリ内のpagesディレクトリ内にLeaveディレクトリを作成し、index.tsxファイルを作成します。
(src/components/pages/Leave/index.tsx)
import React, { useState, FormEvent } from 'react';
import { useRouter } from 'next/router';
const Leave: React.FC = () => {
const [selectedReason, setSelectedReason] = useState<string>('');
const [otherReason, setOtherReason] = useState<string>('');
const [errorOtherReason, setErrorOtherReason] = useState<string | null>(null);
const router = useRouter();
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (selectedReason === 'other' && !otherReason.trim()) {
setErrorOtherReason('理由を入力してください。');
return;
} else {
setErrorOtherReason(null);
}
// POSTリクエストで退会理由を送信
try {
const response = await fetch('api/leave', {
method: 'POST',
headers: {
'Content-Type' : 'application/json',
},
body: JSON.stringify({
userId: 'user_id',
selectedReason,
otherReason: selectedReason === 'other' ? otherReason : '',
}),
});
if (response.ok) {
const result = await response.json();
console.log(result);
// 退会完了ページにリダイレクト
router.push('/leave/confirm');
} else {
console.error('退会手続きに失敗しました。')
}
} catch (error) {
console.error('エラーが発生しました:', 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ファイルを作成します。
/* 全体のスタイル */
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;
}
退会画面の最初の画面は、下のようになります。
バックエンド
Laravelでは以下の手順で実装します。
1. ルートの設定
routes/api.phpに以下を追加します。
use App\Http\Controllers\LeaveController;
Route::post('/leave', [LeaveController::class, 'store']);
2. コントローラーの作成
$ php artisan make:controller LeaveController
を実行してapp/Http/Controllersディレクトリ内に、LeaveController.phpファイルが作成されます。
そして、以下の内容を記述します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class LeaveController extends Controller
{
public function store(Request $request)
{
// 入力データのバリデーション
$validatedData = $request->validate([
'userId' => 'required|integer',
'selectedReason' => 'required|string',
'otherReason' => 'nullable|string',
]);
$userId = $validatedData['userId'];
$selectedReason = $validatedData['selectedReason'];
$otherReason = $validatedData['otherReason'] ?? null;
// 理由の値をマッピング
$reasonMapping = [
'no_fun' => '仕事がつまらないから',
'change_job' => '転職が決まったから',
'no_job' => '入れる日数が少ないから',
'relationship' => '人間関係が悪いから',
'other' => 'その他',
];
// 日本語の理由に変換
$reasonText = $reasonMapping[$selectedReason] ?? '不明な理由';
try {
// 退会理由を保存
DB::table('leave_reasons')->insert([
'user_id' => $userId,
'reason' => $reasonText, // 日本語の理由を保存
'other_reason' => $selectedReason === 'other' ? $otherReason : null,
'created_at' => now(),
]);
return response()->json([
'status' => 'success',
'message' => '退会理由が正常に登録されました。',
], 200);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => '処理中にエラーが発生しました: ' . $e->getMessage(),
], 500);
}
}
}
マイグレーションの作成
$ php artisan make:migration create_leave_reasons_table
を実行すると、
databse/migrationsディレクトリ内にXXXX_XX_XX_XXXXX_create_leave_reasons_table.phpといったファイルが作成されます。
そこに、以下のコードを追加します。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateLeaveReasonsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('leave_reasons', function (Blueprint $table) {
$table->id(); // 自動インクリメントの主キー
+ $table->unsignedBigInteger('user_id'); // ユーザーID
+ $table->string('reason', 255); // 退会理由(日本語文字列を保存)
+ $table->text('other_reason')->nullable(); // その他の理由(任意)
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); // users テーブルの該当ユーザーが削除された場合、関連する退会理由も自動的に削除
$table->timestamps(); // created_at と updated_at カラム
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('leave_reasons');
}
}
マイグレーションを適用
マイグレーションファイルを作成・修正後、以下のコマンドを実行してデータベースに反映します。
$ php artisan migrate
補足
マイグレーションファイルに修正が必要な場合、最初に適用済みのマイグレーションをロールバックしてから再度適用します。
$ php artisan migrate:rollback
$ php artisan migrate
退会理由確認画面ページ
Leaveディレクトリ内にConfirm.tsxファイルを作成します。
(src/components/pages/Leave/Confirm.tsx)
import { useRouter } from 'next/router';
import { useMutation, useQueryClient } from 'react-query';
const LeaveConfirm: React.FC = () => {
const router = useRouter();
const queryClient = useQueryClient();
// Routerから渡されるクエリパラメーターやstateの取得
const {
selectedReason = '',
otherReason = '',
userId = '' } = router.query
const mutation = useMutation(async () => {
// ここで退会処理を行う
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/leave`, {
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: () => {
// 退会完了後、完了ページに遷移
router.push('/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={() => router.push('/leave')}>キャンセル</button>
</div>
</div>
);
};
export default LeaveConfirm;
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です。
1. ルーティングの設定
この「退会する」ボタンを押した時、ユーザーのIDや登録情報を削除するようにするには、
通常、退会の処理はAPIとして実装されることが多いので、routes/api.phpにコードを追加することをお勧めします。
use App\Http\Controllers\LeaveConfirmController;
Route::post('/leave/confirm', [LeaveConfirmController::class, 'leave']);
2. コントローラーの作成
次に、退会処理を行うコントローラーを作成します。LeaveConfirmControllerを作成して、その中に退会処理のロジックを記述します。
$ php artisan make:controller LeaveConfirmController
app/Http/Controllersディレクトリ内にLeaveConfirmController.phpというファイルが作成されます。
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Models\User;
class LeaveConfirmController extends Controller
{
public function leave(Request $request)
{
// バリデーション
$validated = $request->validate([
'userId' => 'required|integer',
'selectedReason' => 'required|string',
'otherReason' => 'nullable|string',
]);
try {
$userId = $validated['userId'];
$selectedReason = $validated['selectedReason'];
$otherReason = $validated['otherReason'] ?? null;
// 退会理由をleave_reasonsテーブルに保存
DB::table('leave_reasons')->insert([
'user_id' => $userId,
'reason' => $selectedReason,
'other_reason' => $otherReason,
'created_at' => now(),
'updated_at' => now(),
]);
// ユーザー情報を削除
$user = User::find($userId);
if ($user) {
$user->delete();
}
return response()->json([
'status' => 'success',
'message' => '退会理由が登録され、ユーザーが削除されました。',
]);
} catch (\Exception $e) {
Log::error('退会処理エラー: ' . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => '退会処理に失敗しました。再度お試しください。',
], 500);
}
}
}
3. LeaveReason モデルの定義
まず、LeaveReason モデルを作成する必要があります。
$ php artisan make:model LeaveReason
app/Modelsディレクトリ内に、LeaveReason.phpファイルができるので、
そこに、以下のように設定します。
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class LeaveReason extends Model
{
use HasFactory;
// テーブル名の指定(省略しても自動で 'leave_reasons' と推測されますが、明示的に指定することができます)
protected $table = 'leave_reasons';
// マスアサインメント可能なカラム
protected $fillable = [
'user_id',
'reason',
'other_reason',
];
// タイムスタンプを使う場合は、以下のプロパティも指定します(通常は自動でTRUEになります)
public $timestamps = true;
}
4. コントローラーでの LeaveReason モデルの使用
次に、退会理由を leave_reasons テーブルに挿入するために、LeaveController で LeaveReason モデルを使用します。Eloquent を使うと、データの挿入が非常に簡単になります。
app/Http/Controllers/LeaveConfirmController.php の更新をします。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\LeaveReason;
use App\Models\User;
use Illuminate\Support\Facades\Log;
class LeaveConfirmController extends Controller
{
public function leave(Request $request)
{
// バリデーション
$validated = $request->validate([
'userId' => 'required|integer',
'selectedReason' => 'required|string',
'otherReason' => 'nullable|string',
]);
try {
$userId = $validated['userId'];
$selectedReason = $validated['selectedReason'];
$otherReason = $validated['otherReason'] ?? null;
// 退会理由をleave_reasonsテーブルにEloquentで挿入
LeaveReason::create([
'user_id' => $userId,
'reason' => $selectedReason,
'other_reason' => $otherReason,
]);
// ユーザー情報を削除
$user = User::find($userId);
if ($user) {
$user->delete(); // ユーザー情報の削除
}
return response()->json([
'status' => 'success',
'message' => '退会理由が登録され、ユーザーが削除されました。',
]);
} catch (\Exception $e) {
Log::error('退会処理エラー: ' . $e->getMessage());
return response()->json([
'status' => 'error',
'message' => '退会処理に失敗しました。再度お試しください。',
], 500);
}
}
}
5. LeaveReason::create() メソッドについて
LeaveReason::create() は、Eloquentの「マスアサインメント」に基づいて、指定された属性を一度に挿入します。
この方法では、fillable プロパティに指定したカラム(user_id, reason, other_reason)だけが挿入対象になります。それ以外のカラム(例えば、id や created_at, updated_at など)は自動的に管理されます。
6. マスアサインメントを許可するカラム
$fillable プロパティに指定したカラムのみが、create() メソッドを使用して一度に挿入されることができます。
これにより、悪意のあるユーザーが不正にデータを挿入するのを防げます。
今回の場合、user_id, reason, other_reason の3つのカラムがマスアサインメント可能です。
7. timestamps(自動で created_at, updated_at)の使用
LaravelのEloquentでは、timestamps を自動的に管理します。
created_at と updated_at のカラムは、特に設定しなくても自動的に管理されます。
($timestamps = true の場合)。ですので、これらのカラムを手動で設定する必要はありません。
8. マイグレーションの作成
もしまだleave_reasonsテーブルが存在していない場合は、マイグレーションを作成してテーブルを作成します。
$ php artisan make:migration create_leaveconfirm_reasons_table
database/migrationsディレクトリ内に、XXXX_XX_XX_XXXXX_create_leaveconfirm_reasons_table.phpというファイルが作成されるので、
そこに以下を追加します。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateLeaveReasonsTable extends Migration
{
public function up()
{
Schema::create('leaveconfirm_reasons', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('reason');
$table->text('other_reason')->nullable();
$table->timestamps();
// 外部キーの設定(任意)
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
public function down()
{
Schema::dropIfExists('leaveconfirm_reasons');
}
}
9. 実行結果
退会処理を行うと、LeaveReason::create() によって leaveconfirm_reasons テーブルに退会理由が保存されます。
その後、User::find($userId)->delete() によって、指定された userId を持つユーザー情報が users テーブルから削除されます。
その後、マイグレーションを実行します。
$ php artisan migrate
10. CORSの設定(必要な場合)
フロントエンドとバックエンドが別のドメインやポートで動作している場合、CORS(クロスオリジンリソースシェアリング)を設定する必要があります。Laravelでは、CORSの設定はapp/Http/Middleware/HandleCors.phpで管理されています。
もし、CORSの問題が発生する場合は、以下を設定してみてください。
config/cors.phpの設定を確認し、適切な設定を行います。
再度、フロントエンド側に戻り、Leaveディレクトリ内に、退会手続きが完了したことを告げるページ
Complete.tsxファイルを作成します。
(src/components/pages/Leave/Complete.tsx)
import React from 'react';
const LeaveComplete: React.FC = () => {
return (
<div className="container">
<div className="header">
<h1>退会手続き完了</h1>
</div>
<div className="content">
<p>退会手続きが完了しました。</p>
<p>またのご利用をお待ちしています。</p>
</div>
<div>
<a href="/login" className="primary-button">ログイン画面へ</a>
</div>
</div>
);
};
export default LeaveComplete;
そして、退会完了画面のデザインを作成するために、Leaveディレクトリ内にComplete.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; /* サイズ変更を無効化 */
}
.container > div:last-child {
margin-top: 38px; /* ボタン部分全体を下げる */
margin-bottom: 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;
}
完了画面は、こうなります。
ここまでは、ただ、勤怠管理アプリの画面やページのデザインを作成しただけです。
これらのコードは、Reactで、勤怠管理アプリを作成しようとした際に実装したコードです。
現在、Next.jsやLaravelに適応させるために、コードを変更している最中でございます。
随時、更新されるので、都度、確認の方お願いします。
次は、ボタンを押すと、特定のページに遷移できるようにしたり、入力した情報がデータベースに登録できるように、
バックエンドであるlaravelディレクトリの実装をしていきたいと思います。