-
Notifications
You must be signed in to change notification settings - Fork 51
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Feature] Add Weekly/Daily time limits report view (#3376)
* add weekly limit page * add filter options in the header add the weekly limit table * fix spell typo * fix deepscan/codacy * add time limits reports page * add coderabit suggestions
- Loading branch information
Showing
29 changed files
with
1,189 additions
and
33 deletions.
There are no files selected for viewing
186 changes: 186 additions & 0 deletions
186
apps/web/app/[locale]/reports/weekly-limit/components/data-table.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
'use client'; | ||
|
||
import * as React from 'react'; | ||
import { | ||
ColumnDef, | ||
ColumnFiltersState, | ||
SortingState, | ||
VisibilityState, | ||
flexRender, | ||
getCoreRowModel, | ||
getFilteredRowModel, | ||
getPaginationRowModel, | ||
getSortedRowModel, | ||
useReactTable | ||
} from '@tanstack/react-table'; | ||
|
||
import { Checkbox } from '@/components/ui/checkbox'; | ||
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; | ||
import { useTranslations } from 'next-intl'; | ||
import { formatIntegerToHour, formatTimeString } from '@/app/helpers'; | ||
import { ProgressBar } from '@/lib/components'; | ||
|
||
export type WeeklyLimitTableDataType = { | ||
member: string; | ||
timeSpent: number; | ||
limit: number; | ||
percentageUsed: number; | ||
remaining: number; | ||
}; | ||
|
||
/** | ||
* Renders a data table displaying weekly time limits and usage for team members. | ||
* | ||
* @component | ||
* @param {Object} props - The component props. | ||
* @param {WeeklyLimitTableDataType[]} props.data - Array of data objects containing weekly time usage information. | ||
* | ||
* @returns {JSX.Element} A table showing member-wise weekly time limits, usage, and remaining time. | ||
* | ||
*/ | ||
|
||
export function DataTableWeeklyLimits(props: { data: WeeklyLimitTableDataType[] }) { | ||
const [sorting, setSorting] = React.useState<SortingState>([]); | ||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); | ||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}); | ||
const [rowSelection, setRowSelection] = React.useState({}); | ||
const t = useTranslations(); | ||
|
||
const columns: ColumnDef<WeeklyLimitTableDataType>[] = [ | ||
{ | ||
id: 'select', | ||
header: ({ table }) => ( | ||
<div className=""> | ||
<Checkbox | ||
checked={ | ||
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate') | ||
} | ||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} | ||
/> | ||
</div> | ||
), | ||
cell: ({ row }) => ( | ||
<div className=""> | ||
<Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} /> | ||
</div> | ||
), | ||
enableSorting: false, | ||
enableHiding: false | ||
}, | ||
{ | ||
accessorKey: 'member', | ||
header: () => <div className="">{t('common.MEMBER')}</div>, | ||
cell: ({ row }) => <div className="capitalize">{row.getValue('member')}</div> | ||
}, | ||
{ | ||
accessorKey: 'timeSpent', | ||
header: () => <div className="">{t('pages.timeLimitReport.TIME_SPENT')}</div>, | ||
cell: ({ row }) => ( | ||
<div className="lowercase"> | ||
{formatTimeString(formatIntegerToHour(Number(row.getValue('timeSpent')) / 3600))} | ||
</div> | ||
) | ||
}, | ||
{ | ||
accessorKey: 'limit', | ||
header: () => <div className="">{t('pages.timeLimitReport.LIMIT')}</div>, | ||
cell: ({ row }) => ( | ||
<div className="lowercase"> | ||
{formatTimeString(formatIntegerToHour(Number(row.getValue('limit')) / 3600))} | ||
</div> | ||
) | ||
}, | ||
{ | ||
accessorKey: 'percentageUsed', | ||
header: () => <div className="">{t('pages.timeLimitReport.PERCENTAGE_USED')}</div>, | ||
cell: ({ row }) => ( | ||
<div className="lowercase flex gap-2 items-center"> | ||
<ProgressBar | ||
width={'10rem'} | ||
progress={`${Number(row.getValue('percentageUsed')) < 100 ? Number(row.getValue('percentageUsed')).toFixed(2) : 100}%`} | ||
/>{' '} | ||
<span>{`${Number(row.getValue('percentageUsed')).toFixed(2)}%`}</span> | ||
</div> | ||
) | ||
}, | ||
{ | ||
accessorKey: 'remaining', | ||
header: () => <div className="">{t('pages.timeLimitReport.REMAINING')}</div>, | ||
cell: ({ row }) => ( | ||
<div className="lowercase"> | ||
{Number(row.getValue('percentageUsed')) > 100 && '-'} | ||
{formatTimeString(formatIntegerToHour(Number(row.getValue('remaining')) / 3600))} | ||
</div> | ||
) | ||
} | ||
]; | ||
|
||
const table = useReactTable({ | ||
data: props.data, | ||
columns, | ||
onSortingChange: setSorting, | ||
onColumnFiltersChange: setColumnFilters, | ||
getCoreRowModel: getCoreRowModel(), | ||
getPaginationRowModel: getPaginationRowModel(), | ||
getSortedRowModel: getSortedRowModel(), | ||
getFilteredRowModel: getFilteredRowModel(), | ||
onColumnVisibilityChange: setColumnVisibility, | ||
onRowSelectionChange: setRowSelection, | ||
state: { | ||
sorting, | ||
columnFilters, | ||
columnVisibility, | ||
rowSelection | ||
} | ||
}); | ||
|
||
return ( | ||
<div className="w-full"> | ||
{table?.getRowModel()?.rows.length ? ( | ||
<div className="rounded-md"> | ||
<Table> | ||
<TableHeader> | ||
{table.getHeaderGroups().map((headerGroup) => ( | ||
<TableRow key={headerGroup.id}> | ||
{headerGroup.headers.map((header) => { | ||
return ( | ||
<TableHead key={header.id}> | ||
{header.isPlaceholder | ||
? null | ||
: flexRender(header.column.columnDef.header, header.getContext())} | ||
</TableHead> | ||
); | ||
})} | ||
</TableRow> | ||
))} | ||
</TableHeader> | ||
<TableBody> | ||
{table?.getRowModel()?.rows.length ? ( | ||
table?.getRowModel().rows.map((row) => ( | ||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}> | ||
{row.getVisibleCells().map((cell) => ( | ||
<TableCell key={cell.id}> | ||
{flexRender(cell.column.columnDef.cell, cell.getContext())} | ||
</TableCell> | ||
))} | ||
</TableRow> | ||
)) | ||
) : ( | ||
<TableRow> | ||
<TableCell colSpan={columns.length} className="h-24 text-center"> | ||
{t('common.NO_RESULT')} | ||
</TableCell> | ||
</TableRow> | ||
)} | ||
</TableBody> | ||
</Table> | ||
</div> | ||
) : ( | ||
<div className="w-full h-12 flex items-center justify-center"> | ||
<span>{t('common.NO_RESULT')}</span> | ||
</div> | ||
)} | ||
</div> | ||
); | ||
} |
188 changes: 188 additions & 0 deletions
188
apps/web/app/[locale]/reports/weekly-limit/components/date-range-select.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,188 @@ | ||
'use client'; | ||
|
||
import * as React from 'react'; | ||
import { endOfMonth, endOfWeek, format, isSameDay, startOfMonth, startOfWeek, subDays, subMonths } from 'date-fns'; | ||
import { Calendar as CalendarIcon } from 'lucide-react'; | ||
import { DateRange } from 'react-day-picker'; | ||
import { cn } from '@/lib/utils'; | ||
import { Button } from '@/components/ui/button'; | ||
import { Calendar } from '@/components/ui/calendar'; | ||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; | ||
import { useTranslations } from 'next-intl'; | ||
import { useEffect, useMemo } from 'react'; | ||
|
||
/** | ||
* DatePickerWithRange component provides a date range picker with preset ranges. | ||
* Users can select a custom date range or choose from predefined ranges. | ||
* | ||
* @component | ||
* @param {Object} props - The component props. | ||
* @param {DateRange} props.defaultValue - The initial default date range. | ||
* @param {(dateRange: DateRange) => void} props.onChange - Callback function invoked when the date range is changed. | ||
* | ||
* @returns {JSX.Element} A date range picker with custom and preset options. | ||
*/ | ||
|
||
export function DatePickerWithRange({ | ||
onChange, | ||
defaultValue | ||
}: { | ||
defaultValue: DateRange; | ||
onChange: (dateRange: DateRange) => void; | ||
}) { | ||
const [date, setDate] = React.useState<DateRange | undefined>(defaultValue); | ||
const [selectedDate, setSelectedDate] = React.useState<DateRange | undefined>(defaultValue); | ||
const t = useTranslations(); | ||
|
||
return ( | ||
<div className={cn('grid gap-2')}> | ||
<Popover> | ||
<PopoverTrigger asChild> | ||
<Button | ||
id="date" | ||
variant={'outline'} | ||
className={cn( | ||
'w-[16rem] h-[2.2rem] justify-start text-left font-light', | ||
!date && 'text-muted-foreground', | ||
'overflow-hidden h-[2.2rem] text-clip border border-gray-200 dark:border-gray-700 bg-white dark:bg-dark--theme-light focus:ring-2 focus:ring-transparent' | ||
)} | ||
> | ||
<CalendarIcon /> | ||
{date?.from ? ( | ||
date.to ? ( | ||
<> | ||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')} | ||
</> | ||
) : ( | ||
format(date.from, 'LLL dd, y') | ||
) | ||
) : ( | ||
<span>{t('common.PICK_A_DATE')}</span> | ||
)} | ||
</Button> | ||
</PopoverTrigger> | ||
<PopoverContent className="w-auto p-0 flex" align="start"> | ||
<Calendar | ||
initialFocus | ||
mode="range" | ||
defaultMonth={date?.from} | ||
selected={selectedDate} | ||
onSelect={setSelectedDate} | ||
numberOfMonths={2} | ||
/> | ||
<div className="flex flex-col gap-1 w-44 border-l"> | ||
<PresetDates date={selectedDate} setDate={setSelectedDate} /> | ||
<div className="flex p-2 items-center flex-1 gap-1 justify-between"> | ||
<Button className=" grow text-xs h-8" variant={'outline'} size={'sm'}> | ||
{t('common.CANCEL')} | ||
</Button> | ||
<Button | ||
onClick={() => { | ||
if (selectedDate?.from && selectedDate?.to) { | ||
onChange(selectedDate); | ||
setDate(selectedDate); | ||
} else { | ||
console.warn('Invalid date range selected'); | ||
} | ||
}} | ||
className=" grow text-xs h-8 dark:text-white" | ||
size={'sm'} | ||
> | ||
{t('common.APPLY')} | ||
</Button> | ||
</div> | ||
</div> | ||
</PopoverContent> | ||
</Popover> | ||
</div> | ||
); | ||
} | ||
|
||
/** | ||
* PresetDates component displays a list of predefined date ranges that users can select. | ||
* It updates the selected date range in the parent DatePickerWithRange component. | ||
* | ||
* @component | ||
* @param {Object} props - The component props. | ||
* @param {React.Dispatch<React.SetStateAction<DateRange | undefined>>} props.setDate - Function to set the selected date range. | ||
* @param {DateRange | undefined} props.date - The currently selected date range. | ||
* | ||
* @returns {JSX.Element} A list of buttons representing preset date ranges. | ||
*/ | ||
|
||
const PresetDates = ({ | ||
setDate, | ||
date | ||
}: { | ||
setDate: React.Dispatch<React.SetStateAction<DateRange | undefined>>; | ||
date: DateRange | undefined; | ||
}) => { | ||
const t = useTranslations(); | ||
|
||
const presets = useMemo( | ||
() => [ | ||
{ label: t('common.TODAY'), range: { from: new Date(), to: new Date() } }, | ||
{ label: t('common.YESTERDAY'), range: { from: subDays(new Date(), 1), to: subDays(new Date(), 1) } }, | ||
{ label: t('common.THIS_WEEK'), range: { from: startOfWeek(new Date()), to: endOfWeek(new Date()) } }, | ||
{ | ||
label: t('common.LAST_WEEK'), | ||
range: { from: startOfWeek(subDays(new Date(), 7)), to: endOfWeek(subDays(new Date(), 7)) } | ||
}, | ||
{ label: t('common.THIS_MONTH'), range: { from: startOfMonth(new Date()), to: endOfMonth(new Date()) } }, | ||
{ | ||
label: t('common.LAST_MONTH'), | ||
range: { | ||
from: startOfMonth(subMonths(new Date(), 1)), | ||
to: endOfMonth(subMonths(new Date(), 1)) | ||
} | ||
}, | ||
{ label: t('common.FILTER_LAST_7_DAYS'), range: { from: subDays(new Date(), 7), to: new Date() } }, | ||
{ label: t('common.LAST_TWO_WEEKS'), range: { from: subDays(new Date(), 14), to: new Date() } } | ||
], | ||
[t] | ||
); | ||
|
||
const [selected, setSelected] = React.useState<DateRange>(); | ||
|
||
useEffect(() => { | ||
setSelected( | ||
presets.find((preset) => { | ||
return ( | ||
date?.from && | ||
date.to && | ||
isSameDay(date?.from, preset.range.from) && | ||
isSameDay(date?.to, preset.range.to) | ||
); | ||
})?.range | ||
); | ||
}, [date?.from, date?.to, presets, date]); | ||
|
||
return ( | ||
<div className="flex flex-col w-full p-2 gap-1"> | ||
{presets.map((preset) => ( | ||
<Button | ||
key={preset.label} | ||
onClick={() => setDate(preset.range)} | ||
variant={ | ||
selected?.from && | ||
selected.to && | ||
isSameDay(selected?.from, preset.range.from) && | ||
isSameDay(selected?.to, preset.range.to) | ||
? 'default' | ||
: 'outline' | ||
} | ||
className={cn( | ||
' truncate text-left text-sm h-8 px-2 py-1 font-normal border rounded', | ||
selected?.from && | ||
selected.to && | ||
isSameDay(selected?.from, preset.range.from) && | ||
isSameDay(selected?.to, preset.range.to) && | ||
'dark:text-white' | ||
)} | ||
> | ||
{preset.label} | ||
</Button> | ||
))} | ||
</div> | ||
); | ||
}; |
Oops, something went wrong.