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環境でNext.js(TypeScript)とLaravel(PHP)を利用して、勤怠管理アプリを作成してみる。〜勤怠管理ページ編〜

Last updated at Posted at 2025-01-23

前回は、ログイン、新規登録、パスワード再設定ページの作成をやっていきました。

次は、勤怠管理ページ、マイページ、作業日報ページを作成していきます。

1.勤怠管理ページ

「出勤」や「退勤」などのボタンを押した際、トースト通知が出るようにするために、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/TimeTracking/index.tsx
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}/time_tracking`, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({ type }),
        });

        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;

デザインの調整

frontend/src/components/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です。

Laravelでは、フロントエンドで登録された情報をデータベースに反映させるために、コントローラー、ルート、モデル、マイグレーションを作成する必要があります。

マイグレーションとモデルの作成

attendanceとreportsのデータを扱うマイグレーションとモデルを作成します。

マイグレーション
以下のコマンドでマイグレーションファイルを作成します。

$ php artisan make:migration create_attendance_table

すると、database/migrationsディレクトリ内に

XXXX_XX_XX_XXXXX_create_attendance_table.phpと

いうファイルが作成されます。

作成されたこのマイグレーションファイルにテーブル構造を記述します。

laravel/database/migrations/XXXX_XX_XX_XXXXX_create_attendance_table.php

public function up()
{
    Schema::create('attendance', function (Blueprint $table) {
        $table->id();
+ $table->foreignId('user_id')->constrained('users')->onDelete('cascade');
+ $table->enum('type', ['in', 'out', 'breakStart', 'breakEnd']);
+ $table->timestamp('time');
        $table->timestamps();
    });
}

その後、以下のコマンドでマイグレーションを実行してテーブルを作成します。

$ php artisan migrate

モデルの作成

Attendanceのモデルを生成します。

$ php artisan make:model Attendance

すると、app/Modelsディレクトリ内に、

Attendance.phpが作成されると思います。

そこのモデルに以下を追加します。

laravel/app/Models/Attendance.php

class Attendance extends Model
{
+ protected $fillable = ['user_id', 'type', 'time'];

+ protected $casts = [
+ 'time' => 'datetime',
+ ];

+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
}

コントローラーの作成

次は、コントローラーを作成します。

$ php artisan make:controller TimeTrackingController

app/Http/Controllersディレクトリ内に、

TimeTrackingController.phpというファイルが作成されます。

そこに以下を記述します。

laravel/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);
        }

        if ($type === 'breakStart' && !Attendance::where('user_id', $userId)->where('type', 'in')->whereDate('time', now())->exists()) {
            return response()->json(['message' => 'まだ出勤していません。'], 400);
        }

        if ($type === 'breakEnd' && !Attendance::where('user_id', $userId)->where('type', 'breakStart')->whereDate('time', now())->exists()) {
            return response()->json(['message' => '休憩を開始していません。'], 400);
        }

        Attendance::create([
            'user_id' => $userId,
            'type' => $type,
            'time' => $currentTime,
        ]);

        return response()->json(['message' => '登録が完了しました。']);
    }

    public function getAttendance(Request $request)
    {
        $userId = Auth::id();
        $year = $request->input('year', now()->year);
        $month = $request->input('month', now()->month);

        $attendances = Attendance::where('user_id', $userId)
            ->whereYear('time', $year)
            ->whereMonth('time', $month)
            ->orderBy('time', 'asc')
            ->paginate(31);

        $summary = $this->calculateWorkAndBreakTime($attendances);

        return response()->json([
            'attendance' => $attendances,
            'totalBreakHours' => $summary['totalBreakHours'],
            'remainingBreakMinutes' => $summary['remainingBreakMinutes'],
            'actualWorkHours' => $summary['actualWorkHours'],
            'remainingActualWorkMinutes' => $summary['remainingActualWorkMinutes'],
        ]);
    }

    private function calculateWorkAndBreakTime($attendances)
    {
        $totalBreakMinutes = 0;
        $totalWorkMinutes = 0;
        $lastCheckInTime = null;
        $lastBreakStartTime = null;

        foreach ($attendances as $attendance) {
            if ($attendance->type === 'in') {
                $lastCheckInTime = $attendance->time;
            } elseif ($attendance->type === 'breakStart') {
                $lastBreakStartTime = $attendance->time;
            } elseif ($attendance->type === 'breakEnd' && $lastBreakStartTime) {
                $totalBreakMinutes += $attendance->time->diffInMinutes($lastBreakStartTime);
                $lastBreakStartTime = null;
            } elseif ($attendance->type === 'out' && $lastCheckInTime) {
                $totalWorkMinutes += $attendance->time->diffInMinutes($lastCheckInTime) - $totalBreakMinutes;
                $lastCheckInTime = null;
            }
        }

        return [
            'totalBreakHours' => intdiv($totalBreakMinutes, 60),
            'remainingBreakMinutes' => $totalBreakMinutes % 60,
            'actualWorkHours' => intdiv($totalWorkMinutes, 60),
            'remainingActualWorkMinutes' => $totalWorkMinutes % 60,
        ];
    }
}

ルートの作成

先ほど、この書いたコードのAPIルートを作成するので、

routes/api.phpファイルに、以下を追加します。

laravel/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']);
});

2.マイページ

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

frontend/src/components/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 = async () => {
            try {
                const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/users`, {
                    method: 'GET',
                    credentials: 'include', // Laravel Sanctumを使用する場合に必要
                    headers: { 'Content-Type': 'application/json'},
                });

            if (response.ok) {
                const userData = await response.json();
                setUser(userData);
            } else {
                console.error('ユーザー情報を取得できませんでした。');
            }
        } catch (error) {
            console.error('ユーザー情報取得中にエラーが発生しました。:', error);
        }
    };
        
        // 次の月の一日以降かどうかをチェック
        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 () => {
            try {
        const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/time_tracking?page=${currentPage}&year=${selectedYear}&month=${selectedMonth}`,
         {
            method: 'GET',
            credentials: 'include', // Laravel Sanctumを使用する場合に必要
            headers: {'Content-Type': 'application/json'},
        });

        if (response.ok) {
            const data = await response.json();
            setAttendanceData(data.data);
            setSummary({
                totalBreakHours: data.meta.total_break_hours,
                remainingBreakMinutes: data.meta.remaining_break_minutes,
                actualWorkHours: data.meta.actual_work_hours,
                remainingActualWorkMinutes: data.meta.remaining_actual_work_minutes,
            });
            setTotalPages(data.meta.last_page); // APIから取得したそうページを設定
            setIsDataVisible(true);
            calculateMonthlyWorkHours(data.data); // 月ごとの合計を計算
        } else {
            setMessage('データを取得できませんでした。')
        }
    } catch (error) {
        console.error('勤怠データ取得中にエラーが発生しました。:', error);
        setMessage('エラーが発生しました。');
    }
};

    const handlePageChange = (page: number) => {
        setCurrentPage(page);
    };
    
    const calculateMonthlyWorkHours = (attendance: any[]) => {
        const totalMinutes = attendance.reduce((acc, entry) => {
            // 実働時間を分単位で取得し、合計
            const workMinutes = entry.actual_work_hours * 60 + entry.remaining_actual_work_minutes;
            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 className="user-container">
            {user && user.icon && (
                <div>
                    <img src={`http://localhost:8080${user.icon}`} alt="ユーザー" width={100} height={100}/>
                </div>
            )}
            <h2>ようこそ、{user?.username}さん</h2> 
            <button className="kintai" 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.report ? (
                                        <>
                                        <td>{entry.title}</td>
                                        <td>{entry.content}</td>
                                        </>
                                    ) : (
                                        <>
                                        <td colSpan={2}>N/A</td></>
                                    )}
                                    <td>{entry.type === 'in' || '未登録'}</td>
                                    <td>{entry.type === 'out' || '未登録'}</td>
                                    <td>{`${entry.actual_work_hours || 0}:${entry.remaining_actual_work_minutes || 0}:00`}</td>
                                    <td>{`${entry.total_break_hours || 0}:${entry.remaining_break_minutes || 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

ここまででしか表示できていないので、こういう画面になります。

Laravel側での処理

database/migrations/XXXX_XX_XX_XXXXX_create_attendances_table.phpを編集して、テーブル構造を定義します。

laravel/database/migrations/XXXX_XX_XX_XXXXX_create_attendance_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->foreignId('user_id')->constrained('users')->onDelete('cascade');
            $table->enum('type', ['in', 'out', 'breakStart', 'breakEnd']);
+ $table->string('username'); // ユーザー名
+ $table->string('title')->nullable(); // 作業タイトル
+ $table->text('content')->nullable(); // 作業内容
+ $table->time('in')->nullable(); // 出勤時間
+ $table->time('out')->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();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('attendances');
    }
};

マイグレーションを実行します。

$ php artisan migrate

Attendanceモデルを以下のように修正して、データベースとのやり取りを定義します。

laravel/app/Models/Attendance.php

<?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',
    ];

    protected $casts = [
        'time' => 'datetime',
    ];

    // ユーザーとのリレーション
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

コントローラの作成

AttendanceControllerを作成します。

$ php artisan make:controller AttendanceController

app/Http/Controllersディレクトリ内に作成されたAttendanceController.phpに以下のコードを追加します。

laravel/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,
        ]);
    }
}

3.作業日報ページ

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(`${process.env.NEXT_PUBLIC_API_URL}/report`, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify(newTask),
        });

        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

のようなデザインになります。

マイグレーションファイル

Reactコンポーネントで送信されるデータを格納するためのデータベーステーブルを作成します。たとえば、作業日報を保存するreportsテーブルを用意します。

$ php artisan make:migration create_reports_table

database/migrations/ディレクトリ内に
XXXX_XX_XX_XXXXX_create_reports_table.phpというファイルが作成されます。

そこに以下を記述します。

laravel/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');
    }
};

モデルファイル

Reportのモデルを作成します。

$ php artisan make:model Report

すると、app/Modelsディレクトリ内に、Report.phpが生成されます。

そこに以下を記述します。

laravel/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);
    }
}

コントローラー

次は、コントローラーを作成します。

$ php artisan make:controller ReportController

app/Http/Controllersディレクトリ内に、

ReportController.phpというファイルが作成されます。

このコントローラーでは、データの保存処理を行います。

そこに以下を記述します。

laravel/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);
    }
}

ルート設定

フロントエンドがリクエストを送信するエンドポイントを設定します。

マイページと作業日報ページを作成したので、

routes/api.phpファイルに以下を追加します。

laravel/routes/api.php
+ use App\Http\Controllers\AttendanceController;

+ use App\Http\Controllers\ReportController;

+ Route::get('/time_tracking', [AttendanceController::class, 'index']);
+ Route::post('/time_tracking', [AttendanceController::class, 'store']);

+ Route::post('/report', [ReportController::class, 'store']);

認証の設定

ユーザーIDの取得に認証が必要です。Laravel Sanctumを利用するのがおすすめです。

$ composer require laravel/sanctum
$ php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
$ php artisan migrate

app/Http/Kernel.phpに以下を追加します。

laravel/app/Http/Kernel.php
protected $middlewareGroups = [
    'api' => [
+ \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
        'throttle:api',
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

以上の設定で、終わりです。

テストデータの作成

必要に応じて、シーダーやファクトリを使用してデータを生成します。

php artisan make:factory AttendanceFactory --model=Attendance

すると、database/factoriesディレクトリにAttendanceFactory.phpというファイルが作成されます。

勤怠管理ページ編はここまでです。

次は、データベースに登録した情報を消すための処理を行う退会ページの作成を行なっていきます、↓

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?