diff --git a/apps/web/app/[locale]/reports/weekly-limit/components/data-table.tsx b/apps/web/app/[locale]/reports/weekly-limit/components/data-table.tsx index c83435356..bc2369146 100644 --- a/apps/web/app/[locale]/reports/weekly-limit/components/data-table.tsx +++ b/apps/web/app/[locale]/reports/weekly-limit/components/data-table.tsx @@ -22,7 +22,7 @@ import { formatIntegerToHour, formatTimeString } from '@/app/helpers'; import { ProgressBar } from '@/lib/components'; export type WeeklyLimitTableDataType = { - member: string; + indexValue: string; timeSpent: number; limit: number; percentageUsed: number; @@ -35,12 +35,18 @@ export type WeeklyLimitTableDataType = { * @component * @param {Object} props - The component props. * @param {WeeklyLimitTableDataType[]} props.data - Array of data objects containing weekly time usage information. + * @param {boolean} props.showHeader - If false, hide the header. * * @returns {JSX.Element} A table showing member-wise weekly time limits, usage, and remaining time. * */ -export function DataTableWeeklyLimits(props: { data: WeeklyLimitTableDataType[] }) { +export function DataTableWeeklyLimits(props: { + data: WeeklyLimitTableDataType[]; + indexTitle: string; + showHeader?: boolean; +}) { + const { data, indexTitle, showHeader = true } = props; const [sorting, setSorting] = React.useState([]); const [columnFilters, setColumnFilters] = React.useState([]); const [columnVisibility, setColumnVisibility] = React.useState({}); @@ -69,9 +75,9 @@ export function DataTableWeeklyLimits(props: { data: WeeklyLimitTableDataType[] enableHiding: false }, { - accessorKey: 'member', - header: () =>
{t('common.MEMBER')}
, - cell: ({ row }) =>
{row.getValue('member')}
+ accessorKey: 'indexValue', + header: () =>
{indexTitle}
, + cell: ({ row }) =>
{row.getValue('indexValue')}
}, { accessorKey: 'timeSpent', @@ -117,7 +123,7 @@ export function DataTableWeeklyLimits(props: { data: WeeklyLimitTableDataType[] ]; const table = useReactTable({ - data: props.data, + data: data, columns, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, @@ -140,21 +146,27 @@ export function DataTableWeeklyLimits(props: { data: WeeklyLimitTableDataType[] {table?.getRowModel()?.rows.length ? (
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - + {showHeader && ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + )} + {table?.getRowModel()?.rows.length ? ( table?.getRowModel().rows.map((row) => ( diff --git a/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx b/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx index f9cb705c7..032b830c9 100644 --- a/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx +++ b/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx @@ -1,55 +1,120 @@ -import { Select, SelectTrigger, SelectValue, SelectContent, SelectGroup, SelectItem } from '@components/ui/select'; import { DottedLanguageObjectStringPaths, useTranslations } from 'next-intl'; import { useMemo, useState, useCallback } from 'react'; +import { Fragment } from 'react'; +import { Listbox, Transition } from '@headlessui/react'; +import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'; +import { Badge } from '@components/ui/badge'; + +export type TGroupByOption = 'date' | 'week' | 'member'; interface IProps { - onChange: (OPTION: TGroupByOption) => void; - defaultValue: TGroupByOption; + onChange: (options: TGroupByOption[]) => void; + defaultValues: TGroupByOption[]; } /** - * GroupBySelect component provides a dropdown selector for grouping data by day, week, or member. + * [GroupBySelect] - A multi-select component that allows users to choose up to two options + * from a predefined list ("date", "Week", "Member"). + * + * Rules enforced: + * - Only two options can be selected at a time. + * - "date" and "Week" cannot be selected together. + * - At least one option must remain selected. * * @component - * @param {IProps} props - The component props. - * @param {(option: TGroupByOption) => void} props.onChange - Function to handle changes in the selected grouping option. - * @param {TGroupByOption} props.defaultValue - The initial grouping option. + * @param {Object} props - The properties of the component. + * @param {TGroupByOption[]} props.defaultValues - Initial options selected when the component is rendered. + * @param {Function} props.onChange - Callback function invoked when the selection changes. + * + * @returns {JSX.Element} A custom multi-select dropdown with badges representing selected items. * - * @returns {JSX.Element} A dropdown for selecting a grouping option. */ - -export type TGroupByOption = 'Day' | 'Week' | 'Member'; -export function GroupBySelect(props: IProps) { - const { onChange, defaultValue } = props; - const options = useMemo(() => ['Day', 'Week', 'Member'], []); - const [selected, setSelected] = useState(defaultValue); +export function GroupBySelect({ defaultValues, onChange }: IProps) { + const options = useMemo(() => ['date', 'week', 'member'], []); + const [selected, setSelected] = useState(defaultValues); const t = useTranslations(); + const handleChange = useCallback( - (option: TGroupByOption) => { - setSelected(option); - onChange(option); + (options: TGroupByOption[]) => { + // Ensure 'date' and 'Week' cannot be selected together + let updatedOptions = options; + + if (options.includes('date') && options.includes('week')) { + // If 'date' is newly selected, remove 'Week' + if (selected.includes('week')) { + updatedOptions = options.filter((option) => option !== 'week'); + } + // If 'Week' is newly selected, remove 'date' + else if (selected.includes('date')) { + updatedOptions = options.filter((option) => option !== 'date'); + } + } + + setSelected(updatedOptions); + onChange(updatedOptions); }, - [onChange] + [onChange, selected] ); return ( - + + ))} + + + + ); } diff --git a/apps/web/app/[locale]/reports/weekly-limit/components/time-report-table.tsx b/apps/web/app/[locale]/reports/weekly-limit/components/time-report-table.tsx index 7b8349e6c..392b4b32c 100644 --- a/apps/web/app/[locale]/reports/weekly-limit/components/time-report-table.tsx +++ b/apps/web/app/[locale]/reports/weekly-limit/components/time-report-table.tsx @@ -1,12 +1,14 @@ -import { ITimeLimitReport } from '@/app/interfaces/ITimeLimits'; +import { ITimeLimitReport, ITimeLimitReportByEmployee } from '@/app/interfaces/ITimeLimits'; import { DataTableWeeklyLimits } from './data-table'; -import moment from 'moment'; import { DEFAULT_WORK_HOURS_PER_DAY } from '@/app/constants'; +import moment from 'moment'; -interface IProps { +interface ITimeReportTableProps { report: ITimeLimitReport; - displayMode: 'Week' | 'Day'; + displayMode: 'week' | 'date'; organizationLimits: { [key: string]: number }; + indexTitle: string; + header: JSX.Element; } /** @@ -15,24 +17,22 @@ interface IProps { * @component * @param {IProps} props - The component props. * @param {ITimeLimitReport} props.report - Data for employees' time usage reports. - * @param {'Week' | 'Day'} props.displayMode - Specifies whether to display data by week or day. + * @param {'week' | 'date'} props.displayMode - Specifies whether to display data by week or day. * @param {{ [key: string]: number }} props.organizationLimits - Contains organizational limits for time usage, specified by mode. + * @param {JSX.Element} - props.header - The table header * * @returns {JSX.Element} A formatted report table showing time usage and limits. */ -export const TimeReportTable = ({ report, displayMode, organizationLimits }: IProps) => ( +export const TimeReportTable = ({ + report, + displayMode, + organizationLimits, + indexTitle, + header +}: ITimeReportTableProps) => (
-

- {displayMode === 'Week' ? ( - <> - {report.date} - - {moment(report.date).endOf('week').format('YYYY-MM-DD')} - - ) : ( - report.date - )} -

+ {header}
); + +interface ITimeReportTableByMemberProps { + report: ITimeLimitReportByEmployee; + displayMode: 'week' | 'date'; + organizationLimits: { [key: string]: number }; + indexTitle: string; + header: JSX.Element; +} + +/** + * Renders a time report table displaying time tracking data grouped by employee. + * + * @component + * @param {IProps} props - The component props. + * @param {ITimeLimitReportByEmployee} props.report - Data for employees' time usage reports. + * @param {'week' | 'date'} props.displayMode - Specifies whether to display data by week or day. + * @param {{ [key: string]: number }} props.organizationLimits - Contains organizational limits for time usage, specified by mode. + * @param {JSX.Element} - props.header - The table header + * + * @returns {JSX.Element} A formatted report table showing time usage and limits. + */ +export const TimeReportTableByMember = ({ + report, + displayMode, + organizationLimits, + indexTitle, + header +}: ITimeReportTableByMemberProps) => ( +
+
+ {header} +
+
+ { + const limit = item.limit || organizationLimits[displayMode] || DEFAULT_WORK_HOURS_PER_DAY; + const percentageUsed = (item.duration / limit) * 100; + const remaining = limit - item.duration; + + return { + indexValue: + displayMode == 'week' + ? `${item.date} - ${moment(item.date).endOf('week').format('YYYY-MM-DD')}` + : item.date, + limit, + percentageUsed, + timeSpent: item.duration, + remaining + }; + })} + indexTitle={indexTitle} + /> +
+
+); + +/** + * A helper function that groups employee data by employee ID, consolidating their reports across multiple dates. + * + * @param {Array} data - An array of objects representing daily employee reports. + * @param {string} data[].date - The date of the report. + * @param {Array} data[].employees - A list of employees with their work details for the day. + * @returns {ITimeLimitReportByEmployee[]} - An array of grouped employee reports, each containing the employee details and their corresponding reports. + + */ +export function groupDataByEmployee(data: ITimeLimitReport[]): ITimeLimitReportByEmployee[] { + const grouped = new Map(); + + data.forEach((day) => { + const date = day.date; + day.employees.forEach((emp) => { + const empId = emp.employee.id; + + if (!grouped.has(empId)) { + // Initialize new employee entry in the Map + grouped.set(empId, { + employee: emp.employee, + reports: [] + }); + } + + // Add the report for the current date + grouped.get(empId)?.reports.push({ + date: date, + duration: emp.duration, + durationPercentage: emp.durationPercentage, + limit: emp.limit + }); + }); + }); + + // Convert Map values to an array + return Array.from(grouped.values()); +} diff --git a/apps/web/app/[locale]/reports/weekly-limit/page.tsx b/apps/web/app/[locale]/reports/weekly-limit/page.tsx index ad326015e..14917570a 100644 --- a/apps/web/app/[locale]/reports/weekly-limit/page.tsx +++ b/apps/web/app/[locale]/reports/weekly-limit/page.tsx @@ -19,7 +19,7 @@ import { ITimeLimitReport } from '@/app/interfaces/ITimeLimits'; import { getUserOrganizationsRequest } from '@/app/services/server/requests'; import { IOrganization } from '@/app/interfaces'; import { useTranslations } from 'next-intl'; -import { TimeReportTable } from './components/time-report-table'; +import { groupDataByEmployee, TimeReportTable, TimeReportTableByMember } from './components/time-report-table'; function WeeklyLimitReport() { const { isTrackingEnabled } = useOrganizationTeams(); @@ -28,7 +28,7 @@ function WeeklyLimitReport() { const { timeLimitsReports, getTimeLimitsReport } = useTimeLimits(); const organizationId = getOrganizationIdCookie(); const tenantId = getTenantIdCookie(); - const [groupBy, setGroupBy] = useState('Day'); + const [groupBy, setGroupBy] = useState(['date']); const t = useTranslations(); const breadcrumbPath = useMemo( () => [ @@ -41,8 +41,8 @@ function WeeklyLimitReport() { const organizationLimits = useMemo( () => organization && { - Day: organization.standardWorkHoursPerDay * 3600, - Week: organization.standardWorkHoursPerDay * 3600 * 5 + date: organization.standardWorkHoursPerDay * 3600, + week: organization.standardWorkHoursPerDay * 3600 * 5 }, [organization] ); @@ -54,15 +54,19 @@ function WeeklyLimitReport() { }); const accessToken = useMemo(() => getAccessTokenCookie(), []); const timeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []); + const { total, onPageChange, itemsPerPage, itemOffset, endOffset, setItemsPerPage, currentItems } = usePagination( - groupBy == 'Week' + groupBy.includes('week') ? timeLimitsReports.filter((report) => moment(report.date).isSame(moment(report.date).startOf('isoWeek'), 'day') ) : timeLimitsReports ); + const duration = useMemo(() => groupBy.find((el) => el == 'date' || el == 'week') ?? 'date', [groupBy]); + const displayMode = (groupBy.find((el) => el === 'date' || el === 'week') ?? 'date') as 'week' | 'date'; + // Get the organization useEffect(() => { if (organizationId && tenantId) { @@ -80,14 +84,14 @@ function WeeklyLimitReport() { employeeIds: [...(member === 'all' ? activeTeam?.members.map((m) => m.employeeId) ?? [] : [member])], startDate: dateRange.from?.toISOString(), endDate: dateRange.to?.toISOString(), - duration: groupBy != 'Member' ? groupBy.toLocaleLowerCase() : 'day', + duration: duration == 'date' ? 'day' : duration, timeZone - //TODO : add groupBy query (when it is ready in the API side) }); }, [ activeTeam?.members, dateRange.from, dateRange.to, + duration, getTimeLimitsReport, groupBy, member, @@ -111,7 +115,7 @@ function WeeklyLimitReport() {

- {groupBy == 'Week' ? t('common.WEEKLY_LIMIT') : t('common.DAILY_LIMIT')} + {groupBy.includes('week') ? t('common.WEEKLY_LIMIT') : t('common.DAILY_LIMIT')}

setMember(memberId)} /> @@ -124,44 +128,66 @@ function WeeklyLimitReport() {
{t('common.GROUP_BY')}: - setGroupBy(option)} /> + setGroupBy(option)} />
} > -
- {organization && - organizationLimits && - currentItems.map((report) => { - const displayMode = groupBy != 'Member' ? groupBy : 'Day'; - - if (displayMode == 'Week') { - if (moment(report.date).isSame(moment(report.date).startOf('isoWeek'), 'day')) { +
+ {organization && organizationLimits ? ( + groupBy.includes('member') ? ( + groupDataByEmployee(timeLimitsReports).map((data) => { + return ( + {data.employee.fullName}} + indexTitle={displayMode} + organizationLimits={organizationLimits} + report={data} + displayMode={displayMode} + key={data.employee.fullName} + /> + ); + }) + ) : ( + currentItems + .filter((report) => + displayMode === 'week' + ? moment(report.date).isSame(moment(report.date).startOf('isoWeek'), 'date') + : true + ) + .map((report) => { return ( + {report.date} - + + {moment(report.date).endOf('week').format('YYYY-MM-DD')} + + + ) : ( +

{report.date}

+ ) + } + indexTitle={t('common.MEMBER')} organizationLimits={organizationLimits} report={report} displayMode={displayMode} key={report.date} /> ); - } else { - return null; - } - } else { - return ( - - ); - } - })} + }) + ) + ) : ( +
{t('common.LOADING')}
+ )}
-
+ { + // TODO : Improve the pagination accordingly to filtered data + } +
(items: T[], defaultItemsPerPage = 10) { itemOffset, endOffset, setItemsPerPage, - currentItems + currentItems, + setItemOffset }; } diff --git a/apps/web/app/interfaces/ITimeLimits.ts b/apps/web/app/interfaces/ITimeLimits.ts index 94df0c0f9..97dbc909e 100644 --- a/apps/web/app/interfaces/ITimeLimits.ts +++ b/apps/web/app/interfaces/ITimeLimits.ts @@ -20,3 +20,15 @@ export interface IGetTimeLimitReport { timeZone?: string; duration?: string; } + +// Grouped time limits data + +export interface ITimeLimitReportByEmployee { + employee: IEmployee; + reports: { + date: string; + duration: number; + durationPercentage: number; + limit: number; + }[]; +}