diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimeSheetFilterPopover.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimeSheetFilterPopover.tsx index 743a0e4ab..d241c7650 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimeSheetFilterPopover.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimeSheetFilterPopover.tsx @@ -1,8 +1,8 @@ +import React from "react"; import { useOrganizationTeams, useTeamTasks } from "@app/hooks"; import { Button } from "@components/ui/button"; import { statusOptions } from "@app/constants"; import { MultiSelect } from "lib/components/custom-select"; -import React, { useEffect } from "react"; import { Popover, PopoverContent, @@ -11,16 +11,17 @@ import { import { SettingFilterIcon } from "@/assets/svg"; import { useTranslations } from "next-intl"; import { clsxm } from "@/app/utils"; +import { useTimelogFilterOptions } from "@/app/hooks"; - -export function TimeSheetFilterPopover() { +export const TimeSheetFilterPopover = React.memo(function TimeSheetFilterPopover() { const [shouldRemoveItems, setShouldRemoveItems] = React.useState(false); const { activeTeam } = useOrganizationTeams(); const { tasks } = useTeamTasks(); const t = useTranslations(); + const { setEmployeeState, setProjectState, setStatusState, setTaskState, employee, project, statusState, task } = useTimelogFilterOptions(); - useEffect(() => { + React.useEffect(() => { if (shouldRemoveItems) { setShouldRemoveItems(false); } @@ -46,14 +47,15 @@ export function TimeSheetFilterPopover() { <div className=""> <label className="flex justify-between text-gray-600 mb-1 text-sm"> <span className="text-[12px]">{t('manualTime.EMPLOYEE')}</span> - <span className={clsxm("text-primary/10")}>Clear</span> + <span className={clsxm("text-primary/10", employee?.length > 0 && "text-primary dark:text-primary-light")}>Clear</span> </label> <MultiSelect + localStorageKey="timesheet-select-filter-employee" removeItems={shouldRemoveItems} items={activeTeam?.members ?? []} itemToString={(members) => (members ? members.employee.fullName : '')} itemId={(item) => item.id} - onValueChange={(selectedItems) => console.log(selectedItems)} + onValueChange={(selectedItems) => setEmployeeState(selectedItems as any)} multiSelect={true} triggerClassName="dark:border-gray-700" /> @@ -61,14 +63,15 @@ export function TimeSheetFilterPopover() { <div className=""> <label className="flex justify-between text-gray-600 mb-1 text-sm"> <span className="text-[12px]">{t('sidebar.PROJECTS')}</span> - <span className={clsxm("text-primary/10")}>Clear</span> + <span className={clsxm("text-primary/10", project?.length > 0 && "text-primary dark:text-primary-light")}>Clear</span> </label> <MultiSelect + localStorageKey="timesheet-select-filter-projects" removeItems={shouldRemoveItems} items={activeTeam?.projects ?? []} itemToString={(project) => (activeTeam?.projects ? project.name! : '')} itemId={(item) => item.id} - onValueChange={(selectedItems) => console.log(selectedItems)} + onValueChange={(selectedItems) => setProjectState(selectedItems as any)} multiSelect={true} triggerClassName="dark:border-gray-700" /> @@ -76,12 +79,13 @@ export function TimeSheetFilterPopover() { <div className=""> <label className="flex justify-between text-gray-600 mb-1 text-sm"> <span className="text-[12px]">{t('hotkeys.TASK')}</span> - <span className={clsxm("text-primary/10")}>Clear</span> + <span className={clsxm("text-primary/10", task?.length > 0 && "text-primary dark:text-primary-light")}>Clear</span> </label> <MultiSelect + localStorageKey="timesheet-select-filter-task" removeItems={shouldRemoveItems} items={tasks} - onValueChange={(task) => task} + onValueChange={(selectedItems) => setTaskState(selectedItems as any)} itemId={(task) => (task ? task.id : '')} itemToString={(task) => (task ? task.title : '')} multiSelect={true} @@ -91,14 +95,15 @@ export function TimeSheetFilterPopover() { <div className=""> <label className="flex justify-between text-gray-600 mb-1 text-sm"> <span className="text-[12px]">{t('common.STATUS')}</span> - <span className={clsxm("text-primary/10")}>Clear</span> + <span className={clsxm("text-primary/10", statusState && "text-primary dark:text-primary-light")}>Clear</span> </label> <MultiSelect + localStorageKey="timesheet-select-filter-status" removeItems={shouldRemoveItems} items={statusOptions} itemToString={(status) => (status ? status.value : '')} itemId={(item) => item.value} - onValueChange={(selectedItems) => console.log(selectedItems)} + onValueChange={(selectedItems) => setStatusState(selectedItems)} multiSelect={true} triggerClassName="dark:border-gray-700" /> @@ -121,4 +126,4 @@ export function TimeSheetFilterPopover() { </Popover> </> ) -} +}) diff --git a/apps/web/app/hooks/features/useTimelogFilterOptions.ts b/apps/web/app/hooks/features/useTimelogFilterOptions.ts new file mode 100644 index 000000000..60733481f --- /dev/null +++ b/apps/web/app/hooks/features/useTimelogFilterOptions.ts @@ -0,0 +1,25 @@ +import { timesheetFilterEmployeeState, timesheetFilterProjectState, timesheetFilterStatusState, timesheetFilterTaskState } from '@/app/stores'; +import { useAtom } from 'jotai'; + +export function useTimelogFilterOptions() { + const [employeeState, setEmployeeState] = useAtom(timesheetFilterEmployeeState); + const [projectState, setProjectState] = useAtom(timesheetFilterProjectState); + const [statusState, setStatusState] = useAtom(timesheetFilterStatusState); + const [taskState, setTaskState] = useAtom(timesheetFilterTaskState); + + const employee = employeeState; + const project = projectState; + const task = taskState + + + return { + statusState, + employee, + project, + task, + setEmployeeState, + setProjectState, + setTaskState, + setStatusState + }; +} diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index d2e398ade..c11e196ff 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -6,10 +6,11 @@ import { useCallback, useEffect } from 'react'; import { getTaskTimesheetLogsApi } from '@/app/services/client/api/timer/timer-log'; import moment from 'moment'; import { ITimeSheet } from '@/app/interfaces'; +import { useTimelogFilterOptions } from './useTimelogFilterOptions'; interface TimesheetParams { - startDate: Date | string; - endDate: Date | string; + startDate?: Date | string; + endDate?: Date | string; } export interface GroupedTimesheet { @@ -45,7 +46,7 @@ export function useTimesheet({ }: TimesheetParams) { const { user } = useAuthenticateUser(); const [timesheet, setTimesheet] = useAtom(timesheetRapportState); - + const { employee, project } = useTimelogFilterOptions(); const { loading: loadingTimesheet, queryCall: queryTimesheet } = useQuery(getTaskTimesheetLogsApi); const getTaskTimesheet = useCallback( @@ -59,13 +60,21 @@ export function useTimesheet({ organizationId: user.employee?.organizationId, tenantId: user.tenantId ?? '', timeZone: user.timeZone?.split('(')[0].trim(), + employeeIds: employee?.map((member) => member.employee.id).filter((id) => id !== undefined), + projectIds: project?.map((project) => project.id).filter((id) => id !== undefined) }).then((response) => { setTimesheet(response.data); }).catch((error) => { console.error('Error fetching timesheet:', error); }); }, - [user, queryTimesheet, setTimesheet] + [ + user, + queryTimesheet, + setTimesheet, + employee, + project + ] ); useEffect(() => { getTaskTimesheet({ startDate, endDate }); diff --git a/apps/web/app/hooks/index.ts b/apps/web/app/hooks/index.ts index 93610f55d..5f4642a6a 100644 --- a/apps/web/app/hooks/index.ts +++ b/apps/web/app/hooks/index.ts @@ -40,6 +40,7 @@ export * from './features/useTeamTasks'; export * from './features/useTimer'; export * from './features/useUser'; export * from './features/useUserProfilePage'; +export * from './features/useTimelogFilterOptions'; export * from './useCollaborative'; //export user personal setting diff --git a/apps/web/app/interfaces/timer/ITimerLog.ts b/apps/web/app/interfaces/timer/ITimerLog.ts index f7131863a..3e30da712 100644 --- a/apps/web/app/interfaces/timer/ITimerLog.ts +++ b/apps/web/app/interfaces/timer/ITimerLog.ts @@ -11,7 +11,9 @@ interface Project { interface Task { id: string; title: string; + issueType?: ITaskIssue | null; estimate: number | null; + taskStatus: string | null; taskNumber: string; } @@ -71,5 +73,4 @@ export interface ITimeSheet { employee: Employee; duration: number; isEdited: boolean; - issueType?: ITaskIssue; } diff --git a/apps/web/app/services/client/api/timer/timer-log.ts b/apps/web/app/services/client/api/timer/timer-log.ts index c7b86175b..f3a5b13b4 100644 --- a/apps/web/app/services/client/api/timer/timer-log.ts +++ b/apps/web/app/services/client/api/timer/timer-log.ts @@ -20,13 +20,17 @@ export async function getTaskTimesheetLogsApi({ tenantId, startDate, endDate, - timeZone + timeZone, + projectIds = [], + employeeIds = [] }: { organizationId: string, tenantId: string, startDate: string | Date, endDate: string | Date, - timeZone?: string + timeZone?: string, + projectIds?: string[], + employeeIds?: string[] }) { if (!organizationId || !tenantId || !startDate || !endDate) { @@ -37,7 +41,6 @@ export async function getTaskTimesheetLogsApi({ if (isNaN(new Date(start).getTime()) || isNaN(new Date(end).getTime())) { throw new Error('Invalid date format provided'); } - const params = new URLSearchParams({ 'activityLevel[start]': '0', 'activityLevel[end]': '100', @@ -53,6 +56,13 @@ export async function getTaskTimesheetLogsApi({ 'relations[4]': 'task.taskStatus' }); + projectIds.forEach((id, index) => { + params.append(`projectIds[${index}]`, id); + }); + + employeeIds.forEach((id, index) => { + params.append(`employeeIds[${index}]`, id); + }); const endpoint = `/timesheet/time-log?${params.toString()}`; return get<ITimeSheet[]>(endpoint, { tenantId }); } diff --git a/apps/web/app/stores/index.ts b/apps/web/app/stores/index.ts index 35f126481..5ad723561 100644 --- a/apps/web/app/stores/index.ts +++ b/apps/web/app/stores/index.ts @@ -27,3 +27,4 @@ export * from './integration-tenant'; export * from './integration-github'; export * from './integration-types'; export * from './integration'; +export * from './time-logs' diff --git a/apps/web/app/stores/time-logs.ts b/apps/web/app/stores/time-logs.ts index 3cf5395ea..9f607b4ff 100644 --- a/apps/web/app/stores/time-logs.ts +++ b/apps/web/app/stores/time-logs.ts @@ -1,7 +1,18 @@ import { ITimerLogsDailyReport } from '@app/interfaces/timer/ITimerLogs'; import { atom } from 'jotai'; -import { ITimeSheet } from '../interfaces'; +import { IProject, ITeamTask, ITimeSheet, OT_Member } from '../interfaces'; + +interface IFilterOption { + value: string; + label: string; +} export const timerLogsDailyReportState = atom<ITimerLogsDailyReport[]>([]); export const timesheetRapportState = atom<ITimeSheet[]>([]) + +export const timesheetFilterEmployeeState = atom<OT_Member[]>([]); +export const timesheetFilterProjectState = atom<IProject[]>([]); +export const timesheetFilterTaskState = atom<ITeamTask[]>([]); + +export const timesheetFilterStatusState = atom<IFilterOption | IFilterOption[] | null>([]); diff --git a/apps/web/lib/components/custom-select/multi-select.tsx b/apps/web/lib/components/custom-select/multi-select.tsx index 585758a13..670f79508 100644 --- a/apps/web/lib/components/custom-select/multi-select.tsx +++ b/apps/web/lib/components/custom-select/multi-select.tsx @@ -16,7 +16,8 @@ interface MultiSelectProps<T> { renderItem?: (item: T, onClick: () => void, isSelected: boolean) => JSX.Element; defaultValue?: T | T[]; multiSelect?: boolean; - removeItems?: boolean + removeItems?: boolean; + localStorageKey?: string; } export function MultiSelect<T>({ @@ -29,13 +30,49 @@ export function MultiSelect<T>({ renderItem, defaultValue, multiSelect = false, - removeItems + removeItems, + localStorageKey = "select-items-selected", }: MultiSelectProps<T>) { - const [selectedItems, setSelectedItems] = useState<T[]>(Array.isArray(defaultValue) ? defaultValue : defaultValue ? [defaultValue] : []); + const [selectedItems, setSelectedItems] = useState<T[]>(() => { + if (typeof window === 'undefined') return []; + try { + const saved = localStorage.getItem(localStorageKey); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); const [isPopoverOpen, setPopoverOpen] = useState(false); const [popoverWidth, setPopoverWidth] = useState<number | null>(null); const triggerRef = useRef<HTMLButtonElement>(null); + // Load selected items from localStorage on component mount + useEffect(() => { + if (defaultValue) { + const initialItems = Array.isArray(defaultValue) ? defaultValue : [defaultValue]; + setSelectedItems(initialItems); + } + }, [defaultValue, setSelectedItems]); + + useEffect(() => { + if (onValueChange) { + onValueChange(multiSelect ? selectedItems : selectedItems[0] || null); + } + }, [selectedItems, multiSelect, onValueChange]); + + // Save selected items to localStorage whenever they change + // Handle persistence + useEffect(() => { + if (typeof window !== 'undefined') { + try { + localStorage.setItem(localStorageKey, JSON.stringify(selectedItems)); + } catch (error) { + console.error('Failed to save to localStorage:', error); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItems, localStorageKey]); + const onClick = (item: T) => { let newSelectedItems: T[]; if (multiSelect) { @@ -49,25 +86,15 @@ export function MultiSelect<T>({ setPopoverOpen(false); } setSelectedItems(newSelectedItems); - if (onValueChange) { - onValueChange(multiSelect ? newSelectedItems : newSelectedItems[0]); - } }; - const removeItem = (item: T) => { const newSelectedItems = selectedItems.filter((selectedItem) => itemId(selectedItem) !== itemId(item)); setSelectedItems(newSelectedItems); - if (onValueChange) { - onValueChange(multiSelect ? newSelectedItems : newSelectedItems.length > 0 ? newSelectedItems[0] : null); - } }; const removeAllItems = () => { setSelectedItems([]); - if (onValueChange) { - onValueChange(null); - } }; useEffect(() => { @@ -83,16 +110,6 @@ export function MultiSelect<T>({ }, [removeItems, removeAllItems]) // deepscan-disable-line - useEffect(() => { - const initialItems = Array.isArray(defaultValue) ? defaultValue : defaultValue ? [defaultValue] : []; - setSelectedItems(initialItems); - if (onValueChange) { - onValueChange(multiSelect ? initialItems : initialItems[0] || null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValue]); - - useEffect(() => { if (triggerRef.current) { setPopoverWidth(triggerRef.current.offsetWidth); 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 c530fbfd5..83468b5bb 100644 --- a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx +++ b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx @@ -271,7 +271,7 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { <span className="text-[#868688]">24:30h</span> </Badge> </div> - <div className="flex items-center gap-2 p-x-1"> + <div className={clsxm("flex items-center gap-2 p-x-1")}> {getTimesheetButtons(status as StatusType, t, handleButtonClick)} </div> </div> diff --git a/apps/web/lib/features/task/task-block-card.tsx b/apps/web/lib/features/task/task-block-card.tsx index d84d6985d..c008e6ab6 100644 --- a/apps/web/lib/features/task/task-block-card.tsx +++ b/apps/web/lib/features/task/task-block-card.tsx @@ -76,7 +76,7 @@ export default function TaskBlockCard(props: TaskItemProps) { previousValue + currentValue.duration, 0 )) || - 0 + 0 ); return ( diff --git a/apps/web/lib/features/task/task-issue.tsx b/apps/web/lib/features/task/task-issue.tsx index 4632a8cf3..1b494a2f8 100644 --- a/apps/web/lib/features/task/task-issue.tsx +++ b/apps/web/lib/features/task/task-issue.tsx @@ -221,7 +221,7 @@ export function TaskIssueStatusTimesheet({ }: { task: Nullable<ITimeSheet>; showIssueLabels?: boolean } & IClassName) { return ( <TaskStatus - {...taskIssues[task?.issueType || 'Task']} + {...taskIssues[task?.task.issueType || 'Task']} showIssueLabels={showIssueLabels} issueType="issue" className={clsxm('rounded-md px-2 text-white', className)}