From a9c3b43da899546ec3d186002052d4f9d6daf3d1 Mon Sep 17 00:00:00 2001 From: Elliot Tan <83326039+Somnus22@users.noreply.github.com> Date: Fri, 18 Oct 2024 00:37:43 +0800 Subject: [PATCH 01/16] feat(DateRangePicker): Implement custom DateRangePickerComponent --- package.json | 1 + src/components/apply/datepicker/index.tsx | 1 + src/components/apply/index.tsx | 113 +++++++++------ src/components/apply/modal/index.tsx | 34 ----- .../apply/range_datepicker/index.tsx | 130 ++++++++++++++++++ yarn.lock | 72 +++------- 6 files changed, 224 insertions(+), 127 deletions(-) delete mode 100644 src/components/apply/modal/index.tsx create mode 100644 src/components/apply/range_datepicker/index.tsx diff --git a/package.json b/package.json index 9e2d313..846d28e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react-icons": "^5.3.0", "react-loader-spinner": "^6.1.6", "react-redux": "^9.1.2", + "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/index.tsx b/src/components/apply/index.tsx index 8de3720..cbbc40c 100644 --- a/src/components/apply/index.tsx +++ b/src/components/apply/index.tsx @@ -1,8 +1,11 @@ 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 { DateRangePickerComponent } from "@/components/apply/range_datepicker"; import { H1, BodyLarge, Body, Display } from "@/components/TextStyles"; import Swal from 'sweetalert2'; import { useSelector } from "react-redux"; @@ -11,6 +14,11 @@ import { toast } from "react-toastify"; type ArrangementType = 'AM' | 'PM' | 'FD' | ''; +interface DateRange { + start: string; + end: string; +} + interface SubmitData { staffid: number; date: string; @@ -24,6 +32,7 @@ const Apply: React.FC = () => { const [reason, setReason] = useState(""); const [isFormValid, setIsFormValid] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [dateRange, setDateRange] = useState({start: '', end: ''}); const staffid = useSelector((state: any) => state.auth.staffId); useEffect(() => { @@ -166,6 +175,12 @@ const Apply: React.FC = () => { } }, [staffid, selectedDate, preferredArrangement, reason]); + const handleDateRangeChange = (newDateRange: DateRange) => { + setDateRange(newDateRange); + console.log('Date range changed:', newDateRange); + // You can perform additional actions here, like fetching data based on the new date range + }; + return (
@@ -174,45 +189,65 @@ 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 + + +
+ +
+
+ +
+
); 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..d38d06a --- /dev/null +++ b/src/components/apply/range_datepicker/index.tsx @@ -0,0 +1,130 @@ +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", + 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() + 1); + + endPicker = new Datepicker(endDateRef.current, { + minDate: initialMinDate, + maxDate: maxDate, + format: "yyyy-mm-dd", + 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/yarn.lock b/yarn.lock index 15110bb..8535021 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1219,58 +1219,6 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig== -"@floating-ui/core@1.6.6": - version "1.6.6" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.6.tgz#f6edf703c8acb73e3802cf558c88ddb7cddc4f67" - integrity sha512-Vkvsw6EcpMHjvZZdMkSY+djMGFbt7CRssW99Ne8tar2WLnZ/l3dbxeTShbLQj+/s35h+Qb4cmnob+EzwtjrXGQ== - dependencies: - "@floating-ui/utils" "^0.2.6" - -"@floating-ui/core@^1.6.0": - version "1.6.8" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.8.tgz#aa43561be075815879305965020f492cdb43da12" - integrity sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA== - dependencies: - "@floating-ui/utils" "^0.2.8" - -"@floating-ui/dom@^1.0.0": - version "1.6.11" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.11.tgz#8631857838d34ee5712339eb7cbdfb8ad34da723" - integrity sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ== - dependencies: - "@floating-ui/core" "^1.6.0" - "@floating-ui/utils" "^0.2.8" - -"@floating-ui/react-dom@^2.1.1", "@floating-ui/react-dom@^2.1.2": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31" - integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A== - dependencies: - "@floating-ui/dom" "^1.0.0" - -"@floating-ui/react@0.26.21": - version "0.26.21" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.21.tgz#29fe23a5197650d48eb1b05c5c46ff61df368fb6" - integrity sha512-7P5ncDIiYd6RrwpCDbKyFzvabM014QlzlumtDbK3Bck0UueC+Rp8BLS34qcGBcN1pZCTodl4QNnCVmKv4tSxfQ== - dependencies: - "@floating-ui/react-dom" "^2.1.1" - "@floating-ui/utils" "^0.2.6" - tabbable "^6.0.0" - -"@floating-ui/react@^0.26.23": - version "0.26.24" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.24.tgz#072b9dfeca4e79ef4e3000ef1c28e0ffc86f4ed4" - integrity sha512-2ly0pCkZIGEQUq5H8bBK0XJmc1xIK/RM3tvVzY3GBER7IOD1UgmC2Y2tjj4AuS+TC+vTE1KJv2053290jua0Sw== - dependencies: - "@floating-ui/react-dom" "^2.1.2" - "@floating-ui/utils" "^0.2.8" - tabbable "^6.0.0" - -"@floating-ui/utils@^0.2.6", "@floating-ui/utils@^0.2.8": - version "0.2.8" - resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" - integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig== - "@fortawesome/fontawesome-common-types@6.6.0": version "6.6.0" resolved "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz" @@ -2923,6 +2871,14 @@ bluebird@^3.7.2: resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + brace-expansion@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" @@ -3252,7 +3208,7 @@ clsx@^1.2.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== -clsx@^2.1.0, clsx@^2.1.1: +clsx@^2.0.0, clsx@^2.1.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -7148,7 +7104,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.0, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -7324,6 +7280,14 @@ react-shallow-renderer@^16.15.0: object-assign "^4.1.1" react-is "^16.12.0 || ^17.0.0 || ^18.0.0" +react-tabs@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-6.0.2.tgz#bc1065c3828561fee285a8fd045f22e0fcdde1eb" + integrity sha512-aQXTKolnM28k3KguGDBSAbJvcowOQr23A+CUJdzJtOSDOtTwzEaJA+1U4KwhNL9+Obe+jFS7geuvA7ICQPXOnQ== + dependencies: + clsx "^2.0.0" + prop-types "^15.5.0" + react-test-renderer@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-18.3.1.tgz#e693608a1f96283400d4a3afead6893f958b80b4" From d72460c75c5f48165f45377153fd15c1f11846d2 Mon Sep 17 00:00:00 2001 From: e-tayfw Date: Fri, 18 Oct 2024 23:49:53 +0800 Subject: [PATCH 02/16] api: recurring application wfh requests with automated tests created --- .../Http/Controllers/RequestController.php | 134 ++++++- backend-api/app/Models/Requests.php | 2 + backend-api/routes/api.php | 3 + backend-api/tests/Feature/EmployeeTest.php | 2 +- backend-api/tests/Feature/RequestTest.php | 342 +++++++++++++++++- src/components/apply/index.tsx | 6 +- 6 files changed, 481 insertions(+), 8 deletions(-) diff --git a/backend-api/app/Http/Controllers/RequestController.php b/backend-api/app/Http/Controllers/RequestController.php index 101dc0c..4919463 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; @@ -234,6 +235,135 @@ 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 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'], 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'], 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) { + $duplicate = $existingRequests->where('Date_Requested', $date)->first(); + if ($duplicate) { + return response()->json([ + 'message' => "Duplicate request found on {$date}. Cannot create recurring requests with overlapping dates.", + 'success' => false + ], 409); // 409 Conflict + } + } + + // 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], 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, + '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 +590,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..91521db 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/src/components/apply/index.tsx b/src/components/apply/index.tsx index cbbc40c..a3e1a9c 100644 --- a/src/components/apply/index.tsx +++ b/src/components/apply/index.tsx @@ -6,11 +6,11 @@ import { Selection } from "@/components/apply/selection"; import { Reason } from "@/components/apply/reason"; import { Submit } from "@/components/apply/submit"; import { DateRangePickerComponent } from "@/components/apply/range_datepicker"; -import { H1, BodyLarge, Body, Display } from "@/components/TextStyles"; +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 { toast } from "react-toastify"; type ArrangementType = 'AM' | 'PM' | 'FD' | ''; @@ -53,7 +53,7 @@ const Apply: React.FC = () => { setReason(text); }, []); - const handleSubmit = useCallback(async (event: React.MouseEvent) => { + const handleSubmit = useCallback(async () => { setIsLoading(true); const submitData: SubmitData = { From eb208e99a3fcb1f6797b8b1c72eeca25a7a9a35f Mon Sep 17 00:00:00 2001 From: Elliot Tan <83326039+Somnus22@users.noreply.github.com> Date: Mon, 21 Oct 2024 02:09:33 +0800 Subject: [PATCH 03/16] feat(apply): Integrate API calls with frontend form submission --- .../Http/Controllers/RequestController.php | 32 +- src/components/apply/day/index.tsx | 54 +++ src/components/apply/index.tsx | 348 +++++++++++++----- .../apply/range_datepicker/index.tsx | 2 + 4 files changed, 342 insertions(+), 94 deletions(-) create mode 100644 src/components/apply/day/index.tsx diff --git a/backend-api/app/Http/Controllers/RequestController.php b/backend-api/app/Http/Controllers/RequestController.php index 4919463..667545f 100644 --- a/backend-api/app/Http/Controllers/RequestController.php +++ b/backend-api/app/Http/Controllers/RequestController.php @@ -143,6 +143,7 @@ public function createRequest(Request $request) 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']) @@ -163,7 +164,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, @@ -176,12 +178,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); } @@ -194,6 +197,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, @@ -206,6 +210,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', @@ -238,12 +243,12 @@ 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; + $staffId = $request->staffid; + $startDate = $request->startdate; + $endDate = $request->enddate; $arrangement = $request->arrangement; $reason = $request->reason; - $dayChosen = $request->dayChosen; + $dayChosen = $request->day; // Step 1b: take the current date in (YYYY-MM-DD) format as well $currentDate = date("Y-m-d"); @@ -299,7 +304,11 @@ public function createRecurringRequest(Request $request) if ($duplicate) { return response()->json([ 'message' => "Duplicate request found on {$date}. Cannot create recurring requests with overlapping dates.", - 'success' => false + 'success' => false, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, ], 409); // 409 Conflict } } @@ -308,7 +317,13 @@ public function createRecurringRequest(Request $request) $reportingManager = $employee->Reporting_Manager; $reportingManagerName = ""; if (!$reportingManager) { - return response()->json(['message' => 'Reporting manager not found', 'success' => false], 404); + return response()->json(['message' => 'Reporting manager not found', + 'success' => false, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'arrangement' => $arrangement, + 'reason' => $reason, + ], 404); } else { $reportingManagerRow = Employee::where("Staff_ID", $reportingManager)->firstOrFail(); $reportingManagerName = $reportingManagerRow->Staff_FName . " " . $reportingManagerRow->Staff_LName; @@ -349,6 +364,7 @@ public function createRecurringRequest(Request $request) 'message' => 'Rows for Request and RequestLog have been successfully created', 'success' => true, 'Request_Batch' => $requestBatch, + 'day' => $dayChosen, 'startDate' => $startDate, 'endDate' => $endDate, 'arrangement' => $arrangement, diff --git a/src/components/apply/day/index.tsx b/src/components/apply/day/index.tsx new file mode 100644 index 0000000..5b437cf --- /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 a3e1a9c..44a4735 100644 --- a/src/components/apply/index.tsx +++ b/src/components/apply/index.tsx @@ -6,14 +6,18 @@ import { Selection } from "@/components/apply/selection"; import { Reason } from "@/components/apply/reason"; import { Submit } from "@/components/apply/submit"; 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; @@ -26,118 +30,263 @@ interface SubmitData { reason: string; } +interface SubmitRecurringData { + staffid: number; + startdate: string; + enddate: string; + arrangement: ArrangementType; + day: 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); useEffect(() => { setIsFormValid( - selectedDate !== "" && preferredArrangement !== "" && reason.trim() !== "" + selectedDate !== "" && selectedArrangement !== "" && reason.trim() !== "" ); - }, [selectedDate, preferredArrangement, reason]); + }, [selectedDate, selectedArrangement, reason]); + + useEffect(() => { + const isDateRangeValid = dateRange.start !== '' && + dateRange.end !== '' && + new Date(dateRange.start) <= new Date(dateRange.end); + + setIsRecurringFormValid( + isDateRangeValid && + day !== 0 && + selectedArrangement !== "" && + reason.trim() !== "" + ); + }, [dateRange, day, selectedArrangement, reason]); const handleDateChange = useCallback((date: string) => { setSelectedDate(date); }, []); const handleArrangementChange = useCallback((value: ArrangementType) => { - setPreferredArrangement(value); + setArrangement(value); }, []); const handleReasonChange = useCallback((text: string) => { setReason(text); }, []); - const handleSubmit = useCallback(async () => { - setIsLoading(true); + const handleDayChange = useCallback((value: DayValue) => { + setDay(value); + }, []); - const submitData: SubmitData = { - staffid: staffid, - date: selectedDate, - arrangement: preferredArrangement, - reason: reason, - }; + 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 = (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'; + } + }; + + const handleRecurringSubmit = useCallback(async (event: React.MouseEvent) => { + const submitData: SubmitRecurringData = { + staffid: staffid, + startdate: dateRange.start, + enddate: dateRange.end, + arrangement: selectedArrangement, + day: 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', + // 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' + }); + + // 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; + + let title = 'Request Rejected'; + let 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', + icon: 'error', confirmButtonText: 'OK' }); - // 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' + }); } - 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]); + + const handleSubmit = useCallback(async (event: React.MouseEvent) => { + const submitData: SubmitData = { + staffid: staffid, + date: selectedDate, + arrangement: selectedArrangement, + reason: reason, + }; + + try { + const response = await axios.post("http://127.0.0.1:8085/api/request", submitData, { + headers: { + 'Content-Type': 'application/json', + }, + }); - 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`; + // 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' + }); + + // Reset form fields + setSelectedDate(""); + setArrangement(""); + setReason(""); - } 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}`; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data; + const status = axiosError.response?.status; - } 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"; - + let 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}

@@ -146,9 +295,9 @@ 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)}

`, @@ -156,31 +305,23 @@ const Apply: React.FC = () => { confirmButtonText: 'OK' }); - 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' + }); } - - } 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]); - const handleDateRangeChange = (newDateRange: DateRange) => { - setDateRange(newDateRange); - console.log('Date range changed:', newDateRange); - // You can perform additional actions here, like fetching data based on the new date range - }; + return (
@@ -213,7 +354,7 @@ const Apply: React.FC = () => {
@@ -227,7 +368,7 @@ const Apply: React.FC = () => {

All fields must be filled

- +
@@ -244,6 +385,41 @@ const Apply: React.FC = () => { onDateRangeChange={handleDateRangeChange} /> + + + Select a day + + +
+ +
+ + + Work Arrangement + + +
+ +
+ + Reason + +
+ +
+ +
+ +

All fields must be filled

+
+ +
+ +
+ diff --git a/src/components/apply/range_datepicker/index.tsx b/src/components/apply/range_datepicker/index.tsx index d38d06a..2ea8f9f 100644 --- a/src/components/apply/range_datepicker/index.tsx +++ b/src/components/apply/range_datepicker/index.tsx @@ -37,6 +37,7 @@ const DateRangePickerComponent: React.FC = ({ minDate: minDate, maxDate: maxDate, format: "yyyy-mm-dd", + clearBtn: true, autohide: true, }); @@ -67,6 +68,7 @@ const DateRangePickerComponent: React.FC = ({ minDate: initialMinDate, maxDate: maxDate, format: "yyyy-mm-dd", + clearBtn: true, autohide: true, }); From facc0368dc6b91fd4f9269e64bd279c31e6eacd5 Mon Sep 17 00:00:00 2001 From: Elliot Tan <83326039+Somnus22@users.noreply.github.com> Date: Mon, 21 Oct 2024 02:34:34 +0800 Subject: [PATCH 04/16] test(Apply): Add and refine unit tests for recurring WFH requests --- src/components/apply/day/index.tsx | 2 +- src/components/apply/index.tsx | 10 +-- .../apply/range_datepicker/index.tsx | 4 +- test/jest/Apply.test.tsx | 61 ++++++++++++++++--- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/components/apply/day/index.tsx b/src/components/apply/day/index.tsx index 5b437cf..e664d15 100644 --- a/src/components/apply/day/index.tsx +++ b/src/components/apply/day/index.tsx @@ -38,7 +38,7 @@ const Daypicker: React.FC = ({ selectedDay, onDayChange }) => { id="day-select" value={day} onChange={handleChange} - className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full sm:w-auto p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" + className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:!ring-primary focus:!border-primary block w-full sm:w-auto p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" > {days.map((day) => ( diff --git a/src/components/apply/index.tsx b/src/components/apply/index.tsx index 44a4735..8273de5 100644 --- a/src/components/apply/index.tsx +++ b/src/components/apply/index.tsx @@ -60,12 +60,12 @@ const Apply: React.FC = () => { dateRange.end !== '' && new Date(dateRange.start) <= new Date(dateRange.end); - setIsRecurringFormValid( - isDateRangeValid && + const isFormValid = isDateRangeValid && day !== 0 && selectedArrangement !== "" && - reason.trim() !== "" - ); + reason.trim() !== ""; + + setIsRecurringFormValid(isFormValid); }, [dateRange, day, selectedArrangement, reason]); const handleDateChange = useCallback((date: string) => { @@ -402,7 +402,7 @@ const Apply: React.FC = () => { + /> Reason diff --git a/src/components/apply/range_datepicker/index.tsx b/src/components/apply/range_datepicker/index.tsx index 2ea8f9f..b26944f 100644 --- a/src/components/apply/range_datepicker/index.tsx +++ b/src/components/apply/range_datepicker/index.tsx @@ -101,7 +101,7 @@ const DateRangePickerComponent: React.FC = ({ = ({ ({ - Datecomponent: ({ onDateChange }: { onDateChange: (date: string) => void }) => ( - onDateChange(e.target.value)} /> - ), - })); + Datecomponent: ({ onDateChange }: { onDateChange: (date: string) => void }) => ( + onDateChange(e.target.value)} /> + ), +})); + +jest.mock('@/components/apply/range_datepicker', () => ({ + DateRangePickerComponent: ({ onDateRangeChange }: { onDateRangeChange: (dateRange: {start: string, end: string}) => void }) => ( +
+ onDateRangeChange({start: e.target.value, end: '2024-10-31'})} /> + onDateRangeChange({start: '2024-10-01', end: e.target.value})} /> +
+ ), +})); + +jest.mock('@/components/apply/day', () => ({ + Daypicker: ({ onDayChange }: { onDayChange: (day: number) => void }) => ( + + ), +})); jest.mock('@/components/apply/selection', () => ({ Selection: ({ onSelectionChange }: { onSelectionChange: (value: string) => void }) => ( @@ -36,7 +59,6 @@ jest.mock('@/components/apply/submit', () => ({ ), })); - // Mock the redux store const mockStore = configureStore([]); const store = mockStore({ @@ -45,7 +67,7 @@ const store = mockStore({ }, }); -describe('Apply Component', () => { +describe('Apply Component - Ad-Hoc Form', () => { it('shows all form fields', () => { render( @@ -107,6 +129,27 @@ describe('Apply Component', () => { expect(screen.getByTestId('submit-button')).toBeDisabled(); }); +}); - +describe('Apply Component - Recurring Form', () => { + beforeEach(() => { + render( + + + + ); + fireEvent.click(screen.getByText('Recurring')); + }); + + it('enables submit button when all recurring fields are filled', async () => { + fireEvent.change(screen.getByTestId('start-date-input'), { target: { value: '2024-10-01' } }); + fireEvent.change(screen.getByTestId('end-date-input'), { target: { value: '2024-10-31' } }); + fireEvent.change(screen.getByTestId('day-select'), { target: { value: '1' } }); + fireEvent.change(screen.getByTestId('arrangement-select'), { target: { value: 'FD' } }); + fireEvent.change(screen.getByTestId('reason-input'), { target: { value: 'Recurring test reason' } }); + + await waitFor(() => { + expect(screen.getByTestId('submit-button')).toBeEnabled(); + }); + }); }); \ No newline at end of file From 5187bf4efc7b03d3112792ec4bf74d410c7cfcfd Mon Sep 17 00:00:00 2001 From: Elliot Tan <83326039+Somnus22@users.noreply.github.com> Date: Mon, 21 Oct 2024 19:02:48 +0800 Subject: [PATCH 05/16] refactor: update variable definitions for recurring request --- .../app/Http/Controllers/RequestController.php | 6 +++--- src/components/apply/index.tsx | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend-api/app/Http/Controllers/RequestController.php b/backend-api/app/Http/Controllers/RequestController.php index 667545f..0ed0e36 100644 --- a/backend-api/app/Http/Controllers/RequestController.php +++ b/backend-api/app/Http/Controllers/RequestController.php @@ -243,9 +243,9 @@ 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; + $staffId = $request->staffId; + $startDate = $request->startDate; + $endDate = $request->endDate; $arrangement = $request->arrangement; $reason = $request->reason; $dayChosen = $request->day; diff --git a/src/components/apply/index.tsx b/src/components/apply/index.tsx index 8273de5..14acf04 100644 --- a/src/components/apply/index.tsx +++ b/src/components/apply/index.tsx @@ -31,9 +31,9 @@ interface SubmitData { } interface SubmitRecurringData { - staffid: number; - startdate: string; - enddate: string; + staffId: number; + startDate: string; + endDate: string; arrangement: ArrangementType; day: number; reason: string; @@ -130,9 +130,9 @@ const Apply: React.FC = () => { const handleRecurringSubmit = useCallback(async (event: React.MouseEvent) => { const submitData: SubmitRecurringData = { - staffid: staffid, - startdate: dateRange.start, - enddate: dateRange.end, + staffId: staffid, + startDate: dateRange.start, + endDate: dateRange.end, arrangement: selectedArrangement, day: day, reason: reason, From 7c3b8a22a0cd424b204eec2a7a0267f8bc028757 Mon Sep 17 00:00:00 2001 From: e-tayfw Date: Mon, 21 Oct 2024 19:11:36 +0800 Subject: [PATCH 06/16] test: refactor test directory and request test case --- .../Http/Controllers/RequestController.php | 2 +- test/Apply.test.tsx | 112 -------------- test/ApproveActionHandler.test.tsx | 142 ------------------ test/ApproveTable.test.tsx | 57 ------- test/Schedule.test.tsx | 48 ------ test/WFHCalender.test.tsx | 120 --------------- 6 files changed, 1 insertion(+), 480 deletions(-) delete mode 100644 test/Apply.test.tsx delete mode 100644 test/ApproveActionHandler.test.tsx delete mode 100644 test/ApproveTable.test.tsx delete mode 100644 test/Schedule.test.tsx delete mode 100644 test/WFHCalender.test.tsx diff --git a/backend-api/app/Http/Controllers/RequestController.php b/backend-api/app/Http/Controllers/RequestController.php index 0ed0e36..26feafb 100644 --- a/backend-api/app/Http/Controllers/RequestController.php +++ b/backend-api/app/Http/Controllers/RequestController.php @@ -248,7 +248,7 @@ public function createRecurringRequest(Request $request) $endDate = $request->endDate; $arrangement = $request->arrangement; $reason = $request->reason; - $dayChosen = $request->day; + $dayChosen = $request->dayChosen; // Step 1b: take the current date in (YYYY-MM-DD) format as well $currentDate = date("Y-m-d"); 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 }) => ( -