diff --git a/backend-api/app/Http/Controllers/RequestController.php b/backend-api/app/Http/Controllers/RequestController.php index 101dc0c..cbec1cf 100644 --- a/backend-api/app/Http/Controllers/RequestController.php +++ b/backend-api/app/Http/Controllers/RequestController.php @@ -11,7 +11,8 @@ use App\Jobs\RejectPendingRequestsOlderThanTwoMonthsJob; use Illuminate\Database\Eloquent\ModelNotFoundException; use Carbon\Carbon; -use DB; +use Illuminate\Support\Facades\DB; + use Log; use Mockery\Generator\StringManipulation\Pass\Pass; @@ -139,9 +140,14 @@ public function createRequest(Request $request) $arrangement = $request->arrangement; $reason = $request->reason; + if (strlen($reason) > 255) { + $reason = substr($reason, 0, 255); + } + try { $employee = Employee::where("Staff_ID", $staffId)->firstOrFail(); + // Check if there are existing requests $existingRequests = Requests::where('Requestor_ID', $staffId) ->where('Date_Requested', $selectedDate) ->whereIn('Status', ['Pending', 'Approved', 'Withdraw Rejected', 'Withdraw Pending']) @@ -162,7 +168,8 @@ public function createRequest(Request $request) } elseif ($arrangement === 'FD' && (in_array('AM', $existingArrangements) || in_array('PM', $existingArrangements))) { $message = 'Full day being requested when a half day arrangement already exists'; } - + + // Return failure if existing request condition is met if ($message) { return response()->json([ 'message' => $message, @@ -175,12 +182,13 @@ public function createRequest(Request $request) } } - + $reportingManager = $employee->Reporting_Manager; $reportingManagerFName = ""; $reportingManagerLName = ""; $reportingManagerName = ""; + // Check for reporting manager and return failure if not found if (!$reportingManager) { return response()->json(['message' => 'Reporting manager not found', 'success' => false], 404); } @@ -193,6 +201,7 @@ public function createRequest(Request $request) DB::beginTransaction(); + // Create new row in Request if all conditions are met $newRequest = Requests::create([ 'Requestor_ID' => $staffId, 'Approver_ID' => $reportingManager, @@ -205,6 +214,7 @@ public function createRequest(Request $request) // Dispatch the job to check status after 2 months RejectPendingRequestsOlderThanTwoMonthsJob::dispatch($newRequest)->delay(now()->addMonths(2)); + // Create new row in Request Log RequestLog::create([ 'Request_ID' => $newRequest->Request_ID, 'Previous_State' => 'Pending', @@ -234,6 +244,177 @@ public function createRequest(Request $request) } } + public function createRecurringRequest(Request $request) + { + // Step 1: Retrieve Start Date (YYYY-MM-DD), End Date (YYYY-MM-DD), Staff ID, Arrangement (AM, PM, FD), Reason, Day chosen for Recurring (Integer format) + $staffId = $request->staffId; + $startDate = $request->startDate; + $endDate = $request->endDate; + $arrangement = $request->arrangement; + $reason = $request->reason; + $dayChosen = $request->dayChosen; + + // Step 1a: Check if the reason is more than 255 characters, if more than 255 characters, return the first 255 + if (strlen($reason) > 255) { + $reason = substr($reason, 0, 255); + } + + // Step 1b: take the current date in (YYYY-MM-DD) format as well + $currentDate = date("Y-m-d"); + + // Step 1c: Get current UNIX timestamp in milliseconds + $requestBatch = round(microtime(true) * 1000); // Using microtime for milliseconds + + // Step 1d: Condition to check if $startDate and endDate is more than 3 months apart, if more than 3 months apart, return an error message which state the reason + // **Condition 1**: Validate date range is within 3 months apart + $startDateCarbon = Carbon::createFromFormat('Y-m-d', $startDate); + $endDateCarbon = Carbon::createFromFormat('Y-m-d', $endDate); + if ($startDateCarbon->diffInMonths($endDateCarbon) > 3) { + return response()->json([ + 'message' => 'The date range must be within 3 months apart', + 'success' => false, + 'day' => $dayChosen, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + ], 400); + } + + // Step 2: Retrieve the Staff ID from the Employee Table + try { + $employee = Employee::where("Staff_ID", $staffId)->firstOrFail(); + + // Step 3: Check if there is any pending / approved / withdraw pending / withdraw rejected requests for the staff ID within the start date and end date + $existingRequests = Requests::where('Requestor_ID', $staffId) + ->whereBetween('Date_Requested', [$startDate, $endDate]) + ->whereIn('Status', ['Pending', 'Approved', 'Withdraw Rejected', 'Withdraw Pending']) + ->get(); + + // Step 4: Check for the following conditions + // **Condition 2**: Validate date range is within 2 months back and 3 months forward + $startDateCarbon = Carbon::createFromFormat('Y-m-d', $startDate); + $endDateCarbon = Carbon::createFromFormat('Y-m-d', $endDate); + $currentDateCarbon = Carbon::createFromFormat('Y-m-d', $currentDate); + + $twoMonthAgoFromCurrent = $currentDateCarbon->copy()->subMonths(2); + $threeMonthForwardFromCurrent = $currentDateCarbon->copy()->addMonths(3); + + if ($startDateCarbon->lt($twoMonthAgoFromCurrent) || $endDateCarbon->gte($threeMonthForwardFromCurrent)) { + return response()->json([ + 'message' => 'The date range must be within 2 months back and 3 months forward from the current date', + 'success' => false, + 'day' => $dayChosen, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + ], 400); + } + + // **Condition 3**: Check for duplicate requests on the same day + $recurringDates = []; + $current = $startDateCarbon->copy(); + + while ($current->lte($endDateCarbon)) { + if ($current->dayOfWeek === $dayChosen) { + $recurringDates[] = $current->format('Y-m-d'); + } + $current->addDay(); + } + + // Condition 2b: Check if any of the recurringDates clash with existingRequests + foreach ($recurringDates as $date) { + // Get all existing requests on that date + $duplicates = $existingRequests->where('Date_Requested', $date); + // Step 4a: If there is a duplicate for date_requested, check if the arrangement is the same + if ($duplicates->isNotEmpty()) { + $recurringArrangement = $arrangement; + foreach($duplicates as $duplicate) { + $duplicateArrangement = $duplicate->Duration; + // Condition 4a: If one of the arrangement is FD, duplicate is not allowed + if ($recurringArrangement === 'FD' || $duplicateArrangement === 'FD') { + return response()->json(['message' => 'Duplicate requests cannot be made'], 400); + } + // Condition 4b: If one of the arrangement is AM and the other is PM vice cersa, duplicate will be allowed + elseif ($recurringArrangement === 'AM' && $duplicateArrangement === 'AM' || $recurringArrangement === 'PM' && $duplicateArrangement === 'PM') { + return response()->json(['message' => 'Duplicate requests cannot be made'], 400); + } + else { + continue; + } + } + } + } + + // Step 5: Retrieve the Reporting Manager ID from the Employee Table + $reportingManager = $employee->Reporting_Manager; + $reportingManagerName = ""; + if (!$reportingManager) { + return response()->json(['message' => 'Reporting manager not found', + 'success' => false, + 'startDate' => $startDate, + 'day' => $dayChosen, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + ], 404); + } else { + $reportingManagerRow = Employee::where("Staff_ID", $reportingManager)->firstOrFail(); + $reportingManagerName = $reportingManagerRow->Staff_FName . " " . $reportingManagerRow->Staff_LName; + } + DB::beginTransaction(); + // Step 6: create recurring requests: + $createdRequests = []; + foreach ($recurringDates as $date) { + $newRequest = Requests::create([ + 'Requestor_ID' => $staffId, + 'Approver_ID' => $reportingManager, + 'Status' => 'Pending', + 'Date_Requested' => $date, + 'Request_Batch' => $requestBatch, + 'Date_Of_Request' => now(), + 'Duration' => $arrangement + ]); + // Dispatch the job to check status after 2 months + RejectPendingRequestsOlderThanTwoMonthsJob::dispatch($newRequest)->delay(now()->addMonths(2)); + $createdRequests[] = $newRequest; + } + + // Step 7: Add all following requests to the request log table + foreach ($createdRequests as $request) { + RequestLog::create([ + 'Request_ID' => $request->Request_ID, + 'Previous_State' => 'Pending', + 'New_State' => 'Pending', + 'Employee_ID' => $staffId, + 'Date' => now(), + 'Remarks' => $reason, + ]); + } + + DB::commit(); + + return response()->json([ + 'message' => 'Rows for Request and RequestLog have been successfully created', + 'success' => true, + 'Request_Batch' => $requestBatch, + 'day' => $dayChosen, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + 'reportingManager' => $reportingManagerName + ]); + } catch (ModelNotFoundException $e) { + return response()->json(['message' => 'Employee not found', 'success' => false], 404); + } catch (\Exception $e) { + // Rollback transaction in case of any failure + DB::rollBack(); + return response()->json(['message' => 'Failed to create Request or RequestLog', 'error' => $e->getMessage()], 500); + } + } + public function withdrawRequest(Request $request) { @@ -460,8 +641,6 @@ public function approveRequest(Request $request) // Handle error saving RequestLog return response()->json(['message' => 'Failed to create Request Logs'], 500); } - - return response()->json($requestDB); } // Reject Request diff --git a/backend-api/app/Models/Requests.php b/backend-api/app/Models/Requests.php index d3f742f..fdd25a8 100644 --- a/backend-api/app/Models/Requests.php +++ b/backend-api/app/Models/Requests.php @@ -13,6 +13,8 @@ class Requests extends Model protected $primaryKey = 'Request_ID'; + public $incrementing = true; // Indicates that the primary key is auto-incrementing + protected $guarded = []; } diff --git a/backend-api/routes/api.php b/backend-api/routes/api.php index 6e6e586..bc3db73 100644 --- a/backend-api/routes/api.php +++ b/backend-api/routes/api.php @@ -30,6 +30,9 @@ Route::get(uri: '/request/proportionOfTeam/date/{approver_id}/{date}', action: [RequestController::class, 'getProportionOfTeamOnDate']); Route::post('/request/withdraw', [RequestController::class, 'withdrawRequest']); +// Recurring Requests +Route::post(uri: '/recurringRequest', action: [RequestController::class, 'createRecurringRequest']); + // Schedule Route::get(uri: '/generateOwnSchedule/{staff_id}', action: [ScheduleController::class, 'generateOwnSchedule']); Route::get(uri: '/generateTeamSchedule/{staff_id}', action: [ScheduleController::class, 'generateTeamSchedule']); diff --git a/backend-api/tests/Feature/EmployeeTest.php b/backend-api/tests/Feature/EmployeeTest.php index dba114c..2b532ee 100644 --- a/backend-api/tests/Feature/EmployeeTest.php +++ b/backend-api/tests/Feature/EmployeeTest.php @@ -4,9 +4,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; -use DB; use Log; use Database\Seeders\EmployeeSeeder; +use Illuminate\Support\Facades\DB; class EmployeeTest extends TestCase { diff --git a/backend-api/tests/Feature/RequestTest.php b/backend-api/tests/Feature/RequestTest.php index 3af4cb4..a3da6d0 100644 --- a/backend-api/tests/Feature/RequestTest.php +++ b/backend-api/tests/Feature/RequestTest.php @@ -6,9 +6,13 @@ use Database\Seeders\RequestSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\Requests; +use App\Models\Employee; use Tests\TestCase; -use DB; use Log; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Queue; +use App\Jobs\RejectPendingRequestsOlderThanTwoMonthsJob; +use Carbon\Carbon; class RequestTest extends TestCase { @@ -317,6 +321,342 @@ public function test_create_request_multiple_reqeusts_same_date_different_arrang $response->assertStatus(200); } + /** + * Test creating a valid request for an employee with valid date. + * + * #[Depends('test_database_is_test_db')] + */ + public function test_create_recurring_request_successful() + { + // Prepare data with a non-existent staff ID + $staffId = '140879'; // Assuming this staff ID exist + $startDate = '2024-10-06'; // Valid date + $endDate = '2024-11-13'; // Valid date + $arrangement = 'FD'; // Valid arrangement + $reason = 'Personal'; // Valid reason + $dayReason = 3; + + // Prepare the payload + $payload = [ + 'staffId' => $staffId, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + 'dayReason' => $dayReason + ]; + + // Send POST request to create request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert that the response status is 400 (Bad Request) + $response->assertStatus(200); + } + + /** + * Test overlapping pending requests date. + * + * #[Depends('test_database_is_test_db')] + */ + public function test_duplicate_pending_request_detection() + { + // Assuming a request with the same dates already exists + $existingRequest = Requests::factory()->create([ + 'Requestor_ID' => '140879', + 'Approver_ID' => '151408', // Example approver + 'Status' => 'Pending', + 'Date_Requested' => '2024-10-09', // Date that will clash + 'Request_Batch' => 15, + 'Duration' => 'FD', + ]); + + + // Prepare a payload with the same date as the existing request + $payload = [ + 'staffId' => '140879', + 'startDate' => '2024-10-06', + 'endDate' => '2024-11-13', + 'arrangement' => 'FD', + 'reason' => 'Personal', + 'dayChosen' => 3, + ]; + + // Send POST request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert that the response status is 409 (Conflict) + $response->assertStatus(409); + + // Assert that the response contains the correct error message + $response->assertJson([ + 'message' => 'Duplicate request found on 2024-10-09. Cannot create recurring requests with overlapping dates.', + ]); + } + + /** + * Test overlapping approved requests date. + * + * #[Depends('test_database_is_test_db')] + */ + public function test_duplicate_approved_request_detection() + { + // Assuming a request with the same dates already exists + $existingRequest = Requests::factory()->create([ + 'Requestor_ID' => '140879', + 'Approver_ID' => '151408', // Example approver + 'Status' => 'Approved', + 'Date_Requested' => '2024-10-09', // Date that will clash + 'Request_Batch' => 15, + 'Duration' => 'FD', + ]); + + + // Prepare a payload with the same date as the existing request + $payload = [ + 'staffId' => '140879', + 'startDate' => '2024-10-06', + 'endDate' => '2024-11-13', + 'arrangement' => 'FD', + 'reason' => 'Personal', + 'dayChosen' => 3, + ]; + + // Send POST request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert that the response status is 409 (Conflict) + $response->assertStatus(409); + + // Assert that the response contains the correct error message + $response->assertJson([ + 'message' => 'Duplicate request found on 2024-10-09. Cannot create recurring requests with overlapping dates.', + ]); + } + + /** + * Test overlapping withdraw rejected request dates. + * + * #[Depends('test_database_is_test_db')] + */ + public function test_duplicate_withdraw_rejected_request_detection() + { + // Assuming a request with the same dates already exists + $existingRequest = Requests::factory()->create([ + 'Requestor_ID' => '140879', + 'Approver_ID' => '151408', // Example approver + 'Status' => 'Withdraw Rejected', + 'Date_Requested' => '2024-10-09', // Date that will clash + 'Request_Batch' => 15, + 'Duration' => 'FD', + ]); + + + // Prepare a payload with the same date as the existing request + $payload = [ + 'staffId' => '140879', + 'startDate' => '2024-10-06', + 'endDate' => '2024-11-13', + 'arrangement' => 'FD', + 'reason' => 'Personal', + 'dayChosen' => 3, + ]; + + // Send POST request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert that the response status is 409 (Conflict) + $response->assertStatus(409); + + // Assert that the response contains the correct error message + $response->assertJson([ + 'message' => 'Duplicate request found on 2024-10-09. Cannot create recurring requests with overlapping dates.', + ]); + } + + /** + * Test overlapping withdraw pending request dates. + * + * #[Depends('test_database_is_test_db')] + */ + public function test_duplicate_withdraw_pending_request_detection() + { + // Assuming a request with the same dates already exists + $existingRequest = Requests::factory()->create([ + 'Requestor_ID' => '140879', + 'Approver_ID' => '151408', // Example approver + 'Status' => 'Withdraw Pending', + 'Date_Requested' => '2024-10-09', // Date that will clash + 'Request_Batch' => 15, + 'Duration' => 'FD', + ]); + + + // Prepare a payload with the same date as the existing request + $payload = [ + 'staffId' => '140879', + 'startDate' => '2024-10-06', + 'endDate' => '2024-11-13', + 'arrangement' => 'FD', + 'reason' => 'Personal', + 'dayChosen' => 3, + ]; + + // Send POST request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert that the response status is 409 (Conflict) + $response->assertStatus(409); + + // Assert that the response contains the correct error message + $response->assertJson([ + 'message' => 'Duplicate request found on 2024-10-09. Cannot create recurring requests with overlapping dates.', + ]); + } + + /* + * Test if start date and end date gap is larger than 3 months. + * + * #[Depends('test_database_is_test_db')] + */ + public function test_date_range_more_than_three_months_apart() + { + // Prepare data with a start date and an end date more than 3 months apart + $staffId = '140879'; // Assuming this staff ID exists + $startDate = '2024-01-01'; // January 1st, 2024 + $endDate = '2024-05-01'; // May 1st, 2024 (more than 3 months apart) + $arrangement = 'FD'; // Valid arrangement (Full Day) + $reason = 'Extended leave'; + $dayChosen = 2; // Choosing a day (Tuesday, for example) + + // Prepare the payload + $payload = [ + 'staffId' => $staffId, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + 'dayChosen' => $dayChosen, + ]; + + // Send POST request to create request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert that the response status is 400 (Bad Request) + $response->assertStatus(400); + + // Assert that the response contains the correct error message + $response->assertJson([ + 'message' => 'The date range must be within 3 months apart', + ]); + } + + /** + * Test end date exceeds three months forward from the current date + * + * #[Depends('test_database_is_test_db')] + */ + public function end_date_exceeds_three_months_forward() + { + // Prepare data + $staffId = '140879'; // Assuming this staff ID exists + $startDate = Carbon::now()->addMonths(2)->format('Y-m-d'); + $endDate = Carbon::now()->addMonths(4)->format('Y-m-d'); // More than 3 months forward + $arrangement = 'FD'; + $reason = 'Holiday'; + $dayChosen = 3; + + // Prepare payload + $payload = [ + 'staffId' => $staffId, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + 'dayChosen' => $dayChosen, + ]; + + // Send POST request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert response status is 400 (Bad Request) + $response->assertStatus(400); + + // Assert the response contains the correct message + $response->assertJson([ + 'message' => 'The date range must be within 2 months back and 3 months forward from the current date', + ]); + } + + /** + * Test start date exceeds two months back from the current date + * + * #[Depends('test_database_is_test_db')] + */ + public function start_date_exceeds_two_months_back() + { + // Prepare data + $staffId = '140879'; // Assuming this staff ID exists + $startDate = Carbon::now()->subMonths(3)->format('Y-m-d'); // More than 2 months back + $endDate = Carbon::now()->format('Y-m-d'); + $arrangement = 'FD'; + $reason = 'Late request'; + $dayChosen = 3; + + // Prepare payload + $payload = [ + 'staffId' => $staffId, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + 'dayChosen' => $dayChosen, + ]; + + // Send POST request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert response status is 400 (Bad Request) + $response->assertStatus(400); + + // Assert the response contains the correct message + $response->assertJson([ + 'message' => 'The date range must be within 2 months back and 3 months forward from the current date', + ]); + } + + /** + * Test start date behind the current date + * + * #[Depends('test_database_is_test_db')] + */ + public function test_start_date_behind_current_date() + { + // Prepare data + $staffId = '140878'; // Assuming this staff ID exists + $startDate = Carbon::now()->subMonths(1)->format('Y-m-d'); // Behind current date + $endDate = Carbon::now()->addMonths(1)->format('Y-m-d'); + $arrangement = 'AM'; + $reason = 'Late request'; + $dayChosen = 3; + + // Prepare payload + $payload = [ + 'staffId' => $staffId, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + 'dayChosen' => $dayChosen, + ]; + + // Send POST request + $response = $this->postJson('/api/recurringRequest', $payload); + + // Assert response status is 200 + $response->assertStatus(200); + } + + /** * Test if the API returns a 200 for approve request * diff --git a/package.json b/package.json index 0d3bd48..688066e 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react-loader-spinner": "^6.1.6", "react-redux": "^9.1.2", "react-router-dom": "^6.27.0", + "react-tabs": "^6.0.2", "react-toastify": "^10.0.5", "redux": "^5.0.1", "redux-logger": "^3.0.6", diff --git a/src/components/apply/datepicker/index.tsx b/src/components/apply/datepicker/index.tsx index 44d4d7b..bc5544a 100644 --- a/src/components/apply/datepicker/index.tsx +++ b/src/components/apply/datepicker/index.tsx @@ -30,6 +30,7 @@ const Datecomponent: React.FC = ({ selectedDate, onDateChang maxDate: maxDate, format: "yyyy-mm-dd", clearBtn: true, + autohide: true, }); // Use the changeDate event of the datepicker diff --git a/src/components/apply/day/index.tsx b/src/components/apply/day/index.tsx new file mode 100644 index 0000000..e664d15 --- /dev/null +++ b/src/components/apply/day/index.tsx @@ -0,0 +1,54 @@ +import React, { useState, useEffect } from 'react'; + +type DayValue = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0; + +interface DayProps { + onDayChange: (value: DayValue) => void; + selectedDay: DayValue; +} + +const Daypicker: React.FC = ({ selectedDay, onDayChange }) => { + const [day, setDay] = useState(selectedDay); + + useEffect(() => { + setDay(selectedDay); + }, [selectedDay]); + + useEffect(() => { + onDayChange(day); + }, [day, onDayChange]); + + const handleChange = (event: React.ChangeEvent) => { + setDay(Number(event.target.value) as DayValue); + }; + + const days = [ + { value: 1, label: "Monday" }, + { value: 2, label: "Tuesday" }, + { value: 3, label: "Wednesday" }, + { value: 4, label: "Thursday" }, + { value: 5, label: "Friday" }, + { value: 6, label: "Saturday" }, + { value: 7, label: "Sunday" } + ]; + + return ( +
+ +
+ ); +}; + +export { Daypicker }; \ No newline at end of file diff --git a/src/components/apply/index.tsx b/src/components/apply/index.tsx index 8de3720..bfd99a0 100644 --- a/src/components/apply/index.tsx +++ b/src/components/apply/index.tsx @@ -1,16 +1,28 @@ import React, { useState, useEffect, useCallback } from "react"; +import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; +import 'react-tabs/style/react-tabs.css'; import { Datecomponent } from "@/components/apply/datepicker"; import { Selection } from "@/components/apply/selection"; import { Reason } from "@/components/apply/reason"; import { Submit } from "@/components/apply/submit"; -import { H1, BodyLarge, Body, Display } from "@/components/TextStyles"; +import { DateRangePickerComponent } from "@/components/apply/range_datepicker"; +import { Daypicker } from "@/components/apply/day" +import { Body, Display } from "@/components/TextStyles"; import Swal from 'sweetalert2'; import { useSelector } from "react-redux"; -import axios from 'axios'; -import { toast } from "react-toastify"; +import axios, { AxiosError } from 'axios'; +// import { toast } from "react-toastify"; type ArrangementType = 'AM' | 'PM' | 'FD' | ''; +type DayValue = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0; + + +interface DateRange { + start: string; + end: string; +} + interface SubmitData { staffid: number; date: string; @@ -18,117 +30,280 @@ interface SubmitData { reason: string; } +interface SubmitRecurringData { + staffId: number; + startDate: string; + endDate: string; + arrangement: ArrangementType; + dayChosen: number; + reason: string; +} + const Apply: React.FC = () => { const [selectedDate, setSelectedDate] = useState(""); - const [preferredArrangement, setPreferredArrangement] = useState(""); + const [selectedArrangement, setArrangement] = useState(""); const [reason, setReason] = useState(""); + const [day, setDay] = useState(0); const [isFormValid, setIsFormValid] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [isRecurringFormValid, setIsRecurringFormValid] = useState(false); + const [dateRange, setDateRange] = useState({start: '', end: ''}); const staffid = useSelector((state: any) => state.auth.staffId); + + // Handle submit button for adhoc useEffect(() => { setIsFormValid( - selectedDate !== "" && preferredArrangement !== "" && reason.trim() !== "" + selectedDate !== "" && selectedArrangement !== "" && reason.trim() !== "" ); - }, [selectedDate, preferredArrangement, reason]); + }, [selectedDate, selectedArrangement, reason]); + + // Handle submit button for recurring + useEffect(() => { + const isDateRangeValid = dateRange.start !== '' && + dateRange.end !== '' && + new Date(dateRange.start) <= new Date(dateRange.end); + + const isFormValid = isDateRangeValid && + day !== 0 && + selectedArrangement !== "" && + reason.trim() !== ""; + setIsRecurringFormValid(isFormValid); + }, [dateRange, day, selectedArrangement, reason]); + + // Handle selected date change for adhoc const handleDateChange = useCallback((date: string) => { setSelectedDate(date); }, []); + // Handle arrangement selection change const handleArrangementChange = useCallback((value: ArrangementType) => { - setPreferredArrangement(value); + setArrangement(value); }, []); + // Handle reason change const handleReasonChange = useCallback((text: string) => { setReason(text); }, []); - const handleSubmit = useCallback(async (event: React.MouseEvent) => { - setIsLoading(true); + // Handle selceted day change for recurring + const handleDayChange = useCallback((value: DayValue) => { + setDay(value); + }, []); - const submitData: SubmitData = { - staffid: staffid, - date: selectedDate, - arrangement: preferredArrangement, - reason: reason, - }; + // Handle date range change for recurring + const handleDateRangeChange = useCallback((date: DateRange) => { + setDateRange(date); + },[]); - // Helper function to format existing arrangements - const formatExistingArrangements = (existing: string) => { - const arrangements = existing.split(', '); - return arrangements.map(formatArrangement).join(', '); - }; + // Helper function to format existing arrangements + const formatExistingArrangements = useCallback((existing: string) => { + const arrangements = existing.split(', '); + return arrangements.map(formatArrangement).join(', '); + }, []); - // Helper function to format a single arrangement - const formatArrangement = (arrangement: string) => { - switch (arrangement) { - case 'FD': - return 'Full Day'; - case 'AM': - return 'Half Day (AM)'; - case 'PM': - return 'Half Day (PM)'; - default: - return arrangement; - } - }; + // Helper function to format a single arrangement + const formatArrangement = (arrangement: string) => { + switch (arrangement) { + case 'FD': + return 'Full Day'; + case 'AM': + return 'Half Day (AM)'; + case 'PM': + return 'Half Day (PM)'; + default: + return arrangement; + } + }; + + // Helper function to format day from int + const formatDay = (day: number) => { + switch (day) { + case (1): + return 'Monday'; + case (2): + return 'Tuesday'; + case (3): + return 'Wednesday'; + case (4): + return 'Thursday'; + case (5): + return 'Friday'; + case (6): + return 'Saturday'; + case (7): + return 'Sunday'; + } + }; + + // Submit for recurring + const handleRecurringSubmit = useCallback(async () => { + const submitData: SubmitRecurringData = { + staffId: staffid, + startDate: dateRange.start, + endDate: dateRange.end, + arrangement: selectedArrangement, + dayChosen: day, + reason: reason, + } try { - const response = await axios.post("http://127.0.0.1:8085/api/request", submitData, { + const response = await axios.post("http://127.0.0.1:8085/api/recurringRequest", submitData, { headers: { 'Content-Type': 'application/json', }, }); - if (response.data.success) { - Swal.fire({ - title: 'Request Submitted', + + console.log("Return Data", response.data); + // Successful submission + await Swal.fire({ + title: 'Request Submitted', + html: ` +

Awaiting approval from your Reporting Manager

+
+

Details

+
+
+
+

Selected Day: ${formatDay(response.data.day)}

+

Start Date: ${response.data.startDate}

+

End Date: ${response.data.endDate}

+

Arrangement: ${formatArrangement(response.data.arrangement)}

+

Reason: ${response.data.reason}

+

Reporting Manager: ${response.data.reportingManager}

+
+
+ `, + icon: 'success', + confirmButtonText: 'OK', + confirmButtonColor: '#072040' + }); + + // Reset form fields + setDateRange({start: '', end: ''}); + setDay(0); + setArrangement(""); + setReason(""); + + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data; + // const status = axiosError.response?.status; + + const title = 'Request Rejected'; + const message = responseData.message || 'An error occurred while processing your request'; + + + await Swal.fire({ + title: title, html: ` -

Awaiting approval from your Reporting Manager

+

${message}


Details


-

Date: ${response.data.date}

-

Arrangement: ${formatArrangement(response.data.arrangement)}

-

Reason: ${response.data.reason}

-

Reporting Manager: ${response.data.reportingManager}

+

Selected Day: ${formatDay(responseData.day)}

+

Start Date: ${responseData.startDate}

+

End Date: ${responseData.endDate}

+

Arrangement: ${formatArrangement(responseData.arrangement)}

+

Reason: ${responseData.reason}

`, - icon: 'success', - confirmButtonText: 'OK' + icon: 'error', + confirmButtonText: 'OK', + confirmButtonColor: '#072040' }); - // Reset form fields - setSelectedDate(""); - setPreferredArrangement(""); - setReason(""); + setDateRange({start: '', end: ''}); + setDay(0); + setArrangement(""); + setReason(""); + } else { + console.error("Error in API call:", error); + await Swal.fire({ + title: 'Request Rejected', + text: "An unexpected error occurred. Please try again.", + icon: 'error', + confirmButtonText: 'OK', + confirmButtonColor: '#072040' + }); } - else if(response.data.success == false){ - - const title = 'Request Rejected'; - let message = ''; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const resetDate = false; + } + }, [staffid, dateRange, selectedArrangement, day, reason]); + + + // Submit for adhoc + const handleSubmit = useCallback(async () => { + const submitData: SubmitData = { + staffid: staffid, + date: selectedDate, + arrangement: selectedArrangement, + reason: reason, + }; - if ((response.data.existing === "AM" || response.data.existing === "PM") && response.data.requested === "FD") { - message = `You can't apply for a full day request when a same-day request already exists`; + try { + const response = await axios.post("https://54.251.20.155.nip.io/api/request", submitData, { + headers: { + 'Content-Type': 'application/json', + }, + }); - } else if (response.data.existing === "FD" && response.data.requested !== "FD") { - message = `You have already made a request for a Full Day WFH on ${response.data.date}`; + // Successful submission + await Swal.fire({ + title: 'Request Submitted', + html: ` +

Awaiting approval from your reporting manager

+
+

Details

+
+
+
+

Date: ${response.data.date}

+

Arrangement: ${formatArrangement(response.data.arrangement)}

+

Reason: ${response.data.reason}

+

Reporting Manager: ${response.data.reportingManager}

+
+
+ `, + icon: 'success', + confirmButtonText: 'OK', + confirmButtonColor: '#072040' + }); + + // Reset form fields + setSelectedDate(""); + setArrangement(""); + setReason(""); - } else if (response.data.two == true){ - message = "You already have an AM and PM WFH request.\nNo need to apply for a full day arrangement"; - + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data; + const status = axiosError.response?.status; + + const title = 'Request Rejected'; + let message = ''; + + if (status === 400) { + if ((responseData.existing === "AM" || responseData.existing === "PM") && responseData.requested === "FD") { + message = `You can't apply for a full day request when a same-day request already exists`; + } else if (responseData.existing === "FD" && responseData.requested !== "FD") { + message = `You have already made a request for a Full Day WFH on ${responseData.date}`; + } else if (responseData.two === true) { + message = "You already have an AM and PM WFH request.\nNo need to apply for a full day arrangement"; + } else { + message = `You already have a request for the same WFH arrangement on ${responseData.date}`; + } } else { - message = `You already have a request for the same WFH arrangement on ${response.data.date}`; + message = responseData.message || "An error occurred while processing your request."; } - Swal.fire({ + await Swal.fire({ title: title, html: `

${message}

@@ -137,34 +312,34 @@ const Apply: React.FC = () => {
-

Date: ${response.data.date}

-

Existing Arrangement: ${formatExistingArrangements(response.data.existing)}

-

Requested Arrangement: ${formatArrangement(response.data.requested)}

+

Date: ${responseData.date}

+

Existing Arrangement: ${formatExistingArrangements(responseData.existing)}

+

Requested Arrangement: ${formatArrangement(responseData.requested)}

`, icon: 'error', - confirmButtonText: 'OK' + confirmButtonText: 'OK', + confirmButtonColor: '#072040' }); - setPreferredArrangement(""); + setArrangement(""); setSelectedDate(""); setReason(""); - + } else { + console.error("Error in API call:", error); + await Swal.fire({ + title: 'Request Rejected', + text: "An unexpected error occurred. Please try again.", + icon: 'error', + confirmButtonText: 'OK', + confirmButtonColor: '#072040' + }); } - - } catch (error) { - console.error("Error in API call:", error); - Swal.fire({ - title: 'Request Rejected', - text: error instanceof Error ? error.message : "An unexpected error occurred. Please try again.", - icon: 'error', - confirmButtonText: 'OK' - }); - } finally { - setIsLoading(false); } - }, [staffid, selectedDate, preferredArrangement, reason]); + }, [staffid, selectedDate, selectedArrangement, reason, formatExistingArrangements]); + + return (
@@ -174,45 +349,100 @@ const Apply: React.FC = () => {
-
- - Select a date - -
-
- -
- -
- - Preferred Work Arrangement - -
-
- -
- -
- Reason -
-
- -
- -
- -

All fields must be filled

-
- -
- -
+ + + Ad-Hoc + Recurring + + + +
+ + Select a date + +
+ +
+ + + Work Arrangement + +
+ +
+ + Reason +
+ +
+ +
+ +

All fields must be filled

+
+
+ +
+
+
+ + +
+ + Select a Start Date and End Date + + +
+ +
+ + + Select a day + + +
+ +
+ + + Work Arrangement + + +
+ +
+ + Reason + +
+ +
+ +
+ +

All fields must be filled

+
+ +
+ +
+ +
+ +
+
); diff --git a/src/components/apply/modal/index.tsx b/src/components/apply/modal/index.tsx deleted file mode 100644 index e2ddb7e..0000000 --- a/src/components/apply/modal/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -interface ModalProps { - isOpen: boolean; - onClose: () => void; - title: string; - children: React.ReactNode; -} - -const Modal: React.FC = ({ isOpen, onClose, title, children }) => { - if (!isOpen) return null; - - return ( -
-
-
-

{title}

-
- {children} -
-
- -
-
-
- ); -}; - -export { Modal }; \ No newline at end of file diff --git a/src/components/apply/range_datepicker/index.tsx b/src/components/apply/range_datepicker/index.tsx new file mode 100644 index 0000000..33d5865 --- /dev/null +++ b/src/components/apply/range_datepicker/index.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef } from 'react'; +import 'flowbite/dist/flowbite.min.css'; + +interface DateRange { + start: string; + end: string; +} + +interface DateRangePickerProps { + selectedDateRange: DateRange; + onDateRangeChange: (dateRange: DateRange) => void; +} + +const DateRangePickerComponent: React.FC = ({ + selectedDateRange, + onDateRangeChange, +}) => { + const startDateRef = useRef(null); + const endDateRef = useRef(null); + + useEffect(() => { + if (typeof window !== "undefined") { + import("flowbite-datepicker").then((module) => { + const { Datepicker } = module; + + const today = new Date(); + const minDate = new Date(today); + minDate.setMonth(today.getMonth() - 2); + const maxDate = new Date(today); + maxDate.setMonth(today.getMonth() + 3); + + let startPicker: any; + let endPicker: any; + + if (startDateRef.current) { + startPicker = new Datepicker(startDateRef.current, { + minDate: minDate, + maxDate: maxDate, + format: "yyyy-mm-dd", + clearBtn: true, + autohide: true, + }); + + startDateRef.current.addEventListener("changeDate", (event: Event) => { + const target = event.target as HTMLInputElement; + if (target && target.value) { + onDateRangeChange({ ...selectedDateRange, start: target.value }); + if (endPicker) { + const nextDay = new Date(target.value); + nextDay.setDate(nextDay.getDate() + 1); + endPicker.setOptions({ minDate: nextDay }); + + // Clear end date if it's now invalid + if (selectedDateRange.end && new Date(selectedDateRange.end) <= new Date(target.value)) { + onDateRangeChange({ start: target.value, end: '' }); + endPicker.setDate({ clear: true }); + } + } + } + }); + } + + if (endDateRef.current) { + const initialMinDate = selectedDateRange.start ? new Date(selectedDateRange.start) : minDate; + + initialMinDate.setDate(initialMinDate.getDate() + 6); + + + const endMaxDate = new Date(selectedDateRange.start); + + endMaxDate.setMonth(endMaxDate.getMonth() + 3); + endPicker = new Datepicker(endDateRef.current, { + minDate: initialMinDate, + maxDate: endMaxDate, + format: "yyyy-mm-dd", + clearBtn: true, + autohide: true, + }); + + endDateRef.current.addEventListener("changeDate", (event: Event) => { + const target = event.target as HTMLInputElement; + if (target && target.value) { + onDateRangeChange({ ...selectedDateRange, end: target.value }); + } + }); + } + + return () => { + if (startPicker) startPicker.destroy(); + if (endPicker) endPicker.destroy(); + }; + }).catch((error) => { + console.error("Error loading Flowbite Datepicker:", error); + }); + } + }, [selectedDateRange, onDateRangeChange]); + + return ( +
+
+
+ +
+ +
+
+ to +
+
+
+ +
+ +
+
+ ); +}; + +export { DateRangePickerComponent }; \ No newline at end of file diff --git a/src/components/nav/MobileNav/index.tsx b/src/components/nav/MobileNav/index.tsx index e056951..962bf00 100644 --- a/src/components/nav/MobileNav/index.tsx +++ b/src/components/nav/MobileNav/index.tsx @@ -41,7 +41,7 @@ export const mobileMenuLinks: NavLink[] = [ childPaths: [ { title: "Make a Request", - path: "/request", + path: "/apply", imgUrl: "https://workfromhomebucket.s3.ap-southeast-2.amazonaws.com/Nav/new-request-simu.png", }, diff --git a/test/Apply.test.tsx b/test/Apply.test.tsx deleted file mode 100644 index 78feccb..0000000 --- a/test/Apply.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; -import { Apply } from '@/components/apply'; -import axios from 'axios'; -import '@testing-library/jest-dom'; - -// Mock the imported components -jest.mock('@/components/apply/datepicker', () => ({ - Datecomponent: ({ onDateChange }: { onDateChange: (date: string) => void }) => ( - onDateChange(e.target.value)} /> - ), - })); - -jest.mock('@/components/apply/selection', () => ({ - Selection: ({ onSelectionChange }: { onSelectionChange: (value: string) => void }) => ( - - ), -})); - -jest.mock('@/components/apply/reason', () => ({ - Reason: ({ onReasonChange }: { onReasonChange: (text: string) => void }) => ( -