From e6a9b33e44cb2aa91d4c03a68adf3b4e3f188910 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Thu, 26 Dec 2024 12:28:51 +0200 Subject: [PATCH] [Feat]: Improve entry selection logic (#3484) * feat(timesheet): Improve entry selection logic * refactor(timesheet): Improve search and filtering functionality * fix:coderabbitai --- .../[memberId]/components/AddTaskModal.tsx | 1 - .../[memberId]/components/EditTaskModal.tsx | 2 +- .../components/RejectSelectedModal.tsx | 16 +++- .../[locale]/timesheet/[memberId]/page.tsx | 22 +---- .../hooks/features/useTimelogFilterOptions.ts | 28 ++++-- apps/web/app/hooks/features/useTimesheet.ts | 88 ++++++++++++++++--- .../calendar/table-time-sheet.tsx | 20 ++++- 7 files changed, 130 insertions(+), 47 deletions(-) diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx index 089f36e6d..cfdc306ad 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/AddTaskModal.tsx @@ -188,7 +188,6 @@ export function AddTaskModal({ closeModal, isOpen }: IAddTaskModalProps) { className='w-full font-medium dark:text-white' options={activeTeam?.members || []} onChange={(value) => { - console.log(value) updateFormState('employeeId', value) }} renderOption={(option) => ( diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx index 1cd5ddd81..66a19e4de 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx @@ -171,7 +171,7 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo closeModal={closeModal} isOpen={isOpen} showCloseIcon - title={'Edit Task'} + title={t('common.EDIT_TASK')} className="bg-light--theme-light dark:bg-dark--theme-light p-5 rounded-xl w-full md:w-40 md:min-w-[32rem] justify-start h-[auto]" titleClass="font-bold flex justify-start w-full">
diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx index 1aea6661d..44dc52055 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/RejectSelectedModal.tsx @@ -8,13 +8,27 @@ export interface IRejectSelectedModalProps { onReject: (reason: string) => void; minReasonLength?: number; maxReasonLength?: number; + selectTimesheetId?: string[]; } +/** + * A modal for rejecting selected timesheet entries. + * + * @param isOpen - If true, show the modal. Otherwise, hide the modal. + * @param closeModal - A function to close the modal. + * @param maxReasonLength - The maximum length of the rejection reason. + * @param onReject - A function to call when the user rejects the selected entries. + * @param minReasonLength - The minimum length of the rejection reason. + * @param selectTimesheetId - The IDs of the timesheet entries to be rejected. + * + * @returns A modal component. + */ export function RejectSelectedModal({ isOpen, closeModal, maxReasonLength, onReject, - minReasonLength + minReasonLength, + selectTimesheetId }: IRejectSelectedModalProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [reason, setReason] = useState(''); diff --git a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx index 9f6f9668a..0b3a6a9cc 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx @@ -52,34 +52,18 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb to: endOfMonth(new Date()), }); - const { timesheet, statusTimesheet, loadingTimesheet, isManage } = useTimesheet({ + const { timesheet: filterDataTimesheet, statusTimesheet, loadingTimesheet, isManage } = useTimesheet({ startDate: dateRange.from!, endDate: dateRange.to!, - timesheetViewMode: timesheetNavigator + timesheetViewMode: timesheetNavigator, + inputSearch: search }); React.useEffect(() => { getOrganizationProjects(); }, [getOrganizationProjects]) - const lowerCaseSearch = useMemo(() => search?.toLowerCase() ?? '', [search]); - const filterDataTimesheet = useMemo(() => { - const filteredTimesheet = - timesheet - .filter((v) => - v.tasks.some( - (task) => - task.task?.title?.toLowerCase()?.includes(lowerCaseSearch) || - task.employee?.fullName?.toLowerCase()?.includes(lowerCaseSearch) || - task.project?.name?.toLowerCase()?.includes(lowerCaseSearch) - ) - ); - return filteredTimesheet; - }, [ - timesheet, - lowerCaseSearch, - ]); const { isOpen: isManualTimeModalOpen, openModal: openManualTimeModal, diff --git a/apps/web/app/hooks/features/useTimelogFilterOptions.ts b/apps/web/app/hooks/features/useTimelogFilterOptions.ts index 995992ea4..01fa63bc1 100644 --- a/apps/web/app/hooks/features/useTimelogFilterOptions.ts +++ b/apps/web/app/hooks/features/useTimelogFilterOptions.ts @@ -27,6 +27,14 @@ export function useTimelogFilterOptions() { ]; return user?.role.name ? allowedRoles.includes(user.role.name as RoleNameEnum) : false; }; + const normalizeText = (text: string | undefined | null): string => { + if (!text) return ''; + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim(); + }; const generateTimeOptions = (interval = 15) => { const totalSlots = (24 * 60) / interval; // Total intervals in a day @@ -47,16 +55,17 @@ export function useTimelogFilterOptions() { const handleSelectRowByStatusAndDate = (logs: TimesheetLog[], isChecked: boolean) => { setSelectTimesheetId((prev) => { const logIds = logs.map((item) => item.id); - - if (isChecked) { - return [...new Set([...prev, ...logIds])]; - } else { - return prev.filter((id) => !logIds.includes(id)); + if (!isChecked) { + const allSelected = logIds.every(id => prev.includes(id)); + if (allSelected) { + return prev.filter((id) => !logIds.includes(id)); + } else { + return [...new Set([...prev, ...logIds])]; + } } + return [...new Set([...prev, ...logIds])]; }); - } - - + }; React.useEffect(() => { return () => setSelectTimesheetId([]); @@ -84,6 +93,7 @@ export function useTimelogFilterOptions() { generateTimeOptions, setPuTimesheetStatus, puTimesheetStatus, - isUserAllowedToAccess + isUserAllowedToAccess, + normalizeText }; } diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index b96b8dc67..56cdb231a 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -12,6 +12,7 @@ interface TimesheetParams { startDate?: Date | string; endDate?: Date | string; timesheetViewMode?: 'ListView' | 'CalendarView' + inputSearch?: string } export interface GroupedTimesheet { @@ -88,14 +89,46 @@ const groupByMonth = createGroupingFunction(date => `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}` ); +/** + * @function useTimesheet + * + * @description + * Fetches timesheet logs based on the provided date range and filters. + * + * @param {TimesheetParams} params + * @prop {Date} startDate - Start date of the period to fetch. + * @prop {Date} endDate - End date of the period to fetch. + * @prop {string} timesheetViewMode - "ListView" or "CalendarView" + * @prop {string} inputSearch - Search string to filter the timesheet logs. + * + * @returns + * @prop {boolean} loadingTimesheet - Whether the timesheet is being fetched. + * @prop {TimesheetLog[]} timesheet - The list of timesheet logs, grouped by day. + * @prop {function} getTaskTimesheet - Callable to fetch timesheet logs. + * @prop {boolean} loadingDeleteTimesheet - Whether a timesheet is being deleted. + * @prop {function} deleteTaskTimesheet - Callable to delete timesheet logs. + * @prop {function} getStatusTimesheet - Callable to group timesheet logs by status. + * @prop {TimesheetStatus} timesheetGroupByDays - The current filter for grouping timesheet logs. + * @prop {object} statusTimesheet - Timesheet logs grouped by status. + * @prop {function} updateTimesheetStatus - Callable to update the status of timesheet logs. + * @prop {boolean} loadingUpdateTimesheetStatus - Whether timesheet logs are being updated. + * @prop {boolean} puTimesheetStatus - Whether timesheet logs are updatable. + * @prop {function} createTimesheet - Callable to create a new timesheet log. + * @prop {boolean} loadingCreateTimesheet - Whether a timesheet log is being created. + * @prop {function} updateTimesheet - Callable to update a timesheet log. + * @prop {boolean} loadingUpdateTimesheet - Whether a timesheet log is being updated. + * @prop {function} groupByDate - Callable to group timesheet logs by date. + * @prop {boolean} isManage - Whether the user is authorized to manage the timesheet. + */ export function useTimesheet({ startDate, endDate, - timesheetViewMode + timesheetViewMode, + inputSearch }: TimesheetParams) { const { user } = useAuthenticateUser(); const [timesheet, setTimesheet] = useAtom(timesheetRapportState); - const { employee, project, task, statusState, timesheetGroupByDays, puTimesheetStatus, isUserAllowedToAccess } = useTimelogFilterOptions(); + const { employee, project, task, statusState, timesheetGroupByDays, puTimesheetStatus, isUserAllowedToAccess, normalizeText } = useTimelogFilterOptions(); const { loading: loadingTimesheet, queryCall: queryTimesheet } = useQuery(getTaskTimesheetLogsApi); const { loading: loadingDeleteTimesheet, queryCall: queryDeleteTimesheet } = useQuery(deleteTaskTimesheetLogsApi); const { loading: loadingUpdateTimesheetStatus, queryCall: queryUpdateTimesheetStatus } = useQuery(updateStatusTimesheetFromApi) @@ -266,23 +299,53 @@ export function useTimesheet({ }, [user, queryDeleteTimesheet, setTimesheet]); + const filterDataTimesheet = useMemo(() => { + if (!timesheet || !inputSearch) { + return timesheet; + } + const searchTerms = normalizeText(inputSearch).split(/\s+/).filter(Boolean); + if (searchTerms.length === 0) { + return timesheet; + } + return timesheet.filter((task) => { + const searchableContent = { + title: normalizeText(task.task?.title), + employee: normalizeText(task.employee?.fullName), + project: normalizeText(task.project?.name) + }; + return searchTerms.every(term => + Object.values(searchableContent).some(content => + content.includes(term) + ) + ); + }); + }, [timesheet, inputSearch]); + const timesheetElementGroup = useMemo(() => { + if (!timesheet) { + return []; + } + if (timesheetViewMode === 'ListView') { - if (timesheetGroupByDays === 'Daily') { - return groupByDate(timesheet); + switch (timesheetGroupByDays) { + case 'Daily': + return groupByDate(filterDataTimesheet); + case 'Weekly': + return groupByWeek(filterDataTimesheet); + case 'Monthly': + return groupByMonth(filterDataTimesheet); + default: + return groupByDate(filterDataTimesheet); } - if (timesheetGroupByDays === 'Weekly') { - return groupByWeek(timesheet); - } - return groupByMonth(timesheet); } - return groupByDate(timesheet); + + return groupByDate(filterDataTimesheet); }, [timesheetGroupByDays, timesheetViewMode, timesheet]); useEffect(() => { getTaskTimesheet({ startDate, endDate }); - }, [getTaskTimesheet, startDate, endDate, timesheetGroupByDays]); + }, [getTaskTimesheet, startDate, endDate, timesheetGroupByDays, inputSearch]); return { loadingTimesheet, @@ -292,7 +355,7 @@ export function useTimesheet({ deleteTaskTimesheet, getStatusTimesheet, timesheetGroupByDays, - statusTimesheet: getStatusTimesheet(timesheet.flat()), + statusTimesheet: getStatusTimesheet(filterDataTimesheet.flat()), updateTimesheetStatus, loadingUpdateTimesheetStatus, puTimesheetStatus, @@ -301,6 +364,7 @@ export function useTimesheet({ updateTimesheet, loadingUpdateTimesheet, groupByDate, - isManage + isManage, + normalizeText }; } diff --git a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx index 2c73b7deb..96ca5bde5 100644 --- a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx +++ b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx @@ -117,6 +117,7 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[], countID={selectTimesheetId.length} /> { // Pending implementation }} @@ -186,11 +187,16 @@ export function DataTableTimeSheet({ data, user }: { data?: GroupedTimesheet[], handleSelectRowByStatusAndDate(rows, selectTimesheetId.length === 0)} + () => handleSelectRowByStatusAndDate( + rows, + !rows.every(row => selectTimesheetId.includes(row.id)) + ) + } data={rows} status={status} onSort={handleSort} date={plan.date} + selectedIds={selectTimesheetId} /> {rows.map((task) => (
void, data: TimesheetLog[], handleSelectRowByStatusAndDate: (status: string, date: string) => void, - date?: string + date?: string, + selectedIds: string[] + }) => { const { bg, bgOpacity } = statusColor(status); @@ -536,6 +546,7 @@ const HeaderRow = ({ Employee: null, Status: null, }); + const isAllSelected = data.length > 0 && data.every(row => selectedIds.includes(row.id)); const handleSort = (key: string) => { const newOrder = sortState[key] === "ASC" ? "DESC" : "ASC"; @@ -549,6 +560,7 @@ const HeaderRow = ({ className="flex items-center text-[#71717A] font-medium border-b border-t dark:border-gray-600 space-x-4 p-1 h-[60px] w-full" > date && handleSelectRowByStatusAndDate(status, date)} className="w-5 h-5" disabled={!date} @@ -581,7 +593,7 @@ const HeaderRow = ({ currentSort={sortState["Status"]} />
-
+
Time