From dd55260ad2d456a15b5cc66434d95b0323809ee1 Mon Sep 17 00:00:00 2001 From: syns2191 Date: Sun, 1 Dec 2024 14:01:22 +0700 Subject: [PATCH 1/5] enable edit menu and switch theme on setup --- apps/server-web/src/main/main.ts | 9 +++---- apps/server-web/src/main/menu.ts | 25 ++++++++++++++++++- .../src/renderer/components/Select.tsx | 2 +- .../src/renderer/components/Updater.tsx | 4 +-- .../src/renderer/pages/setup/Landing.tsx | 14 +++++++++-- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/server-web/src/main/main.ts b/apps/server-web/src/main/main.ts index 09db991a2..f633725a7 100644 --- a/apps/server-web/src/main/main.ts +++ b/apps/server-web/src/main/main.ts @@ -197,11 +197,7 @@ const createWindow = async (type: 'SETTING_WINDOW' | 'LOG_WINDOW' | 'SETUP_WINDO url = resolveHtmlPath('index.html', 'setup'); setupWindow?.loadURL(url); mainBindings(ipcMain, setupWindow, fs); - if (process.platform === 'darwin') { - Menu.setApplicationMenu(Menu.buildFromTemplate([])); - } else { - setupWindow.removeMenu(); - } + Menu.setApplicationMenu(appMenu.buildDefaultTemplate(appMenu.initialMenu(), i18nextMainBackend)); setupWindow.on('closed', () => { setupWindow = null; }) @@ -270,6 +266,8 @@ const onInitApplication = () => { trayMenuItems = trayMenuItems.length ? trayMenuItems : defaultTrayMenuItem(eventEmitter); updateTrayMenu('none', {}, eventEmitter, tray, trayMenuItems, i18nextMainBackend); Menu.setApplicationMenu(appMenu.buildDefaultTemplate(appMenuItems, i18nextMainBackend)) + } else { + Menu.setApplicationMenu(appMenu.buildDefaultTemplate(appMenu.initialMenu(), i18nextMainBackend)) } }, 250)); @@ -378,6 +376,7 @@ const onInitApplication = () => { }) logWindow?.webContents.send('themeSignal', { type: SettingPageTypeMessage.themeChange, data }); settingWindow?.webContents.send('themeSignal', { type: SettingPageTypeMessage.themeChange, data }); + setupWindow?.webContents.send('themeSignal', { type: SettingPageTypeMessage.themeChange, data }); }) eventEmitter.on(EventLists.gotoAbout, async () => { diff --git a/apps/server-web/src/main/menu.ts b/apps/server-web/src/main/menu.ts index e95f8f8ba..d997814a9 100644 --- a/apps/server-web/src/main/menu.ts +++ b/apps/server-web/src/main/menu.ts @@ -16,7 +16,7 @@ export default class MenuBuilder { this.eventEmitter = eventEmitter } - defaultMenu(): AppMenu[] { + initialMenu(): AppMenu[] { const isDarwin = process.platform === 'darwin'; return [ { @@ -41,6 +41,25 @@ export default class MenuBuilder { }, ], }, + { + id: 'MENU_APP_EDIT', + label: 'MENU_APP.APP_EDIT', + submenu: [ + { label: 'MENU_APP.APP_SUBMENU.APP_UNDO', accelerator: "CmdOrCtrl+Z", role: "undo" }, + { label: "MENU_APP.APP_SUBMENU.APP_REDO", accelerator: "Shift+CmdOrCtrl+Z", role: "redo" }, + { type: "separator" }, + { label: "MENU_APP.APP_SUBMENU.APP_CUT", accelerator: "CmdOrCtrl+X", role: "cut" }, + { label: "MENU_APP.APP_SUBMENU.APP_COPY", accelerator: "CmdOrCtrl+C", role: "copy" }, + { label: "MENU_APP.APP_SUBMENU.APP_PASTE", accelerator: "CmdOrCtrl+V", role: "paste" }, + { label: "MENU_APP.APP_SUBMENU.APP_SELECT_ALL", accelerator: "CmdOrCtrl+A", role: "selectAll" } + ] + } + ] + } + + defaultMenu(): AppMenu[] { + return [ + ...this.initialMenu(), { id: 'MENU_APP_WINDOW', label: 'MENU_APP.APP_WINDOW', @@ -110,6 +129,10 @@ export default class MenuBuilder { return Menu.buildFromTemplate(this.translateAppMenu(i18nextMainBackend, menuItems)); } + buildInitialTemplate(menuItems: any, i18nextMainBackend: typeof i18n) { + return Menu.buildFromTemplate(this.translateAppMenu(i18nextMainBackend, menuItems)); + } + updateAppMenu(menuItem: string, context: { label?: string, enabled?: boolean}, contextMenuItems: any, i18nextMainBackend: typeof i18n) { const menuIdx:number = contextMenuItems.findIndex((item: any) => item.id === menuItem); if (menuIdx > -1) { diff --git a/apps/server-web/src/renderer/components/Select.tsx b/apps/server-web/src/renderer/components/Select.tsx index 0bf38198a..f03c3fcfa 100644 --- a/apps/server-web/src/renderer/components/Select.tsx +++ b/apps/server-web/src/renderer/components/Select.tsx @@ -42,7 +42,7 @@ export const SelectComponent = ({ onValueChange={onValueChange} > diff --git a/apps/server-web/src/renderer/components/Updater.tsx b/apps/server-web/src/renderer/components/Updater.tsx index 5e5d110b4..c55bbe22b 100644 --- a/apps/server-web/src/renderer/components/Updater.tsx +++ b/apps/server-web/src/renderer/components/Updater.tsx @@ -67,7 +67,7 @@ export const UpdaterComponent = (props: IUpdaterComponent) => { setToastShow(false); }; - const onSelectPeriode = (value: string) => { + const onSelectPeriod = (value: string) => { props.changeAutoUpdate({ autoUpdate: props.data.autoUpdate, updateCheckPeriod: value, @@ -152,7 +152,7 @@ export const UpdaterComponent = (props: IUpdaterComponent) => { value={props.data.updateCheckPeriod} defaultValue={props.data.updateCheckPeriod} disabled={!props.data.autoUpdate} - onValueChange={onSelectPeriode} + onValueChange={onSelectPeriod} /> diff --git a/apps/server-web/src/renderer/pages/setup/Landing.tsx b/apps/server-web/src/renderer/pages/setup/Landing.tsx index 072b6ee06..c142f7b60 100644 --- a/apps/server-web/src/renderer/pages/setup/Landing.tsx +++ b/apps/server-web/src/renderer/pages/setup/Landing.tsx @@ -3,6 +3,7 @@ import { config } from '../../../configs/config'; import { useTranslation } from 'react-i18next'; import LanguageSelector from '../../components/LanguageSelector'; import { useEffect, useState } from 'react'; +import { ThemeToggler } from '../../components/Toggler'; type props = { nextAction: () => void; }; @@ -25,8 +26,17 @@ const Landing = (props: props) => { }, []); return (
-
- +
+
+
+ +
+
+
+
+ +
+
From 1965f0bbbfc0ef4727888b93be522c2e3fed10cb Mon Sep 17 00:00:00 2001 From: "Thierry CH." Date: Sun, 1 Dec 2024 12:52:54 +0200 Subject: [PATCH 2/5] [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 --- .../weekly-limit/components/data-table.tsx | 186 +++++++++++++++++ .../components/date-range-select.tsx | 188 ++++++++++++++++++ .../components/export-mode-select.tsx | 50 +++++ .../components/group-by-select.tsx | 55 +++++ .../components/members-select.tsx | 55 +++++ .../components/time-report-table.tsx | 55 +++++ .../[locale]/reports/weekly-limit/page.tsx | 180 +++++++++++++++++ apps/web/app/constants.ts | 2 + apps/web/app/hooks/features/useTimeLimits.ts | 32 +++ apps/web/app/interfaces/IOrganization.ts | 2 + apps/web/app/interfaces/ITimeLimits.ts | 22 ++ .../client/api/activity/time-limits.ts | 11 + .../services/server/requests/organization.ts | 21 +- apps/web/app/stores/time-limits.ts | 4 + apps/web/components/app-sidebar.tsx | 2 +- apps/web/lib/settings/page-dropdown.tsx | 45 +++-- apps/web/locales/ar.json | 24 ++- apps/web/locales/bg.json | 24 ++- apps/web/locales/de.json | 24 ++- apps/web/locales/en.json | 24 ++- apps/web/locales/es.json | 24 ++- apps/web/locales/fr.json | 24 ++- apps/web/locales/he.json | 24 ++- apps/web/locales/it.json | 24 ++- apps/web/locales/nl.json | 24 ++- apps/web/locales/pl.json | 24 ++- apps/web/locales/pt.json | 24 ++- apps/web/locales/ru.json | 24 ++- apps/web/locales/zh.json | 24 ++- 29 files changed, 1189 insertions(+), 33 deletions(-) create mode 100644 apps/web/app/[locale]/reports/weekly-limit/components/data-table.tsx create mode 100644 apps/web/app/[locale]/reports/weekly-limit/components/date-range-select.tsx create mode 100644 apps/web/app/[locale]/reports/weekly-limit/components/export-mode-select.tsx create mode 100644 apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx create mode 100644 apps/web/app/[locale]/reports/weekly-limit/components/members-select.tsx create mode 100644 apps/web/app/[locale]/reports/weekly-limit/components/time-report-table.tsx create mode 100644 apps/web/app/[locale]/reports/weekly-limit/page.tsx create mode 100644 apps/web/app/hooks/features/useTimeLimits.ts create mode 100644 apps/web/app/interfaces/ITimeLimits.ts create mode 100644 apps/web/app/services/client/api/activity/time-limits.ts create mode 100644 apps/web/app/stores/time-limits.ts 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 new file mode 100644 index 000000000..c83435356 --- /dev/null +++ b/apps/web/app/[locale]/reports/weekly-limit/components/data-table.tsx @@ -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([]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [rowSelection, setRowSelection] = React.useState({}); + const t = useTranslations(); + + const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} /> +
+ ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'member', + header: () =>
{t('common.MEMBER')}
, + cell: ({ row }) =>
{row.getValue('member')}
+ }, + { + accessorKey: 'timeSpent', + header: () =>
{t('pages.timeLimitReport.TIME_SPENT')}
, + cell: ({ row }) => ( +
+ {formatTimeString(formatIntegerToHour(Number(row.getValue('timeSpent')) / 3600))} +
+ ) + }, + { + accessorKey: 'limit', + header: () =>
{t('pages.timeLimitReport.LIMIT')}
, + cell: ({ row }) => ( +
+ {formatTimeString(formatIntegerToHour(Number(row.getValue('limit')) / 3600))} +
+ ) + }, + { + accessorKey: 'percentageUsed', + header: () =>
{t('pages.timeLimitReport.PERCENTAGE_USED')}
, + cell: ({ row }) => ( +
+ {' '} + {`${Number(row.getValue('percentageUsed')).toFixed(2)}%`} +
+ ) + }, + { + accessorKey: 'remaining', + header: () =>
{t('pages.timeLimitReport.REMAINING')}
, + cell: ({ row }) => ( +
+ {Number(row.getValue('percentageUsed')) > 100 && '-'} + {formatTimeString(formatIntegerToHour(Number(row.getValue('remaining')) / 3600))} +
+ ) + } + ]; + + 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 ( +
+ {table?.getRowModel()?.rows.length ? ( +
+ + + {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) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {t('common.NO_RESULT')} + + + )} + +
+
+ ) : ( +
+ {t('common.NO_RESULT')} +
+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/reports/weekly-limit/components/date-range-select.tsx b/apps/web/app/[locale]/reports/weekly-limit/components/date-range-select.tsx new file mode 100644 index 000000000..980e9269b --- /dev/null +++ b/apps/web/app/[locale]/reports/weekly-limit/components/date-range-select.tsx @@ -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(defaultValue); + const [selectedDate, setSelectedDate] = React.useState(defaultValue); + const t = useTranslations(); + + return ( +
+ + + + + + +
+ +
+ + +
+
+
+
+
+ ); +} + +/** + * 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>} 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>; + 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(); + + 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 ( +
+ {presets.map((preset) => ( + + ))} +
+ ); +}; diff --git a/apps/web/app/[locale]/reports/weekly-limit/components/export-mode-select.tsx b/apps/web/app/[locale]/reports/weekly-limit/components/export-mode-select.tsx new file mode 100644 index 000000000..72bb4cdb4 --- /dev/null +++ b/apps/web/app/[locale]/reports/weekly-limit/components/export-mode-select.tsx @@ -0,0 +1,50 @@ +import { Select, SelectTrigger, SelectValue, SelectContent, SelectGroup, SelectItem } from '@components/ui/select'; +import { useMemo, useState, useCallback } from 'react'; + +interface IProps { + onChange: (mode: TExportMode) => void; +} + +/** + * ExportModeSelect component provides a dropdown selector for choosing an export format (Excel or PDF). + * + * @component + * @param {IProps} props - The component props. + * @param {(mode: TExportMode) => void} props.onChange - Function to handle changes in the selected export mode. + * + * @returns {JSX.Element} A dropdown for selecting the export mode. + */ + +type TExportMode = 'Excel' | 'PDF'; +export function ExportModeSelect(props: IProps) { + const { onChange } = props; + const options = useMemo(() => ['Excel', 'PDF'], []); + const [selected, setSelected] = useState(); + + const handleChange = useCallback( + (mode: TExportMode) => { + setSelected(mode); + onChange(mode); + }, + [onChange] + ); + + return ( + + ); +} 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 new file mode 100644 index 000000000..f9cb705c7 --- /dev/null +++ b/apps/web/app/[locale]/reports/weekly-limit/components/group-by-select.tsx @@ -0,0 +1,55 @@ +import { Select, SelectTrigger, SelectValue, SelectContent, SelectGroup, SelectItem } from '@components/ui/select'; +import { DottedLanguageObjectStringPaths, useTranslations } from 'next-intl'; +import { useMemo, useState, useCallback } from 'react'; + +interface IProps { + onChange: (OPTION: TGroupByOption) => void; + defaultValue: TGroupByOption; +} + +/** + * GroupBySelect component provides a dropdown selector for grouping data by day, week, or member. + * + * @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. + * + * @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); + const t = useTranslations(); + const handleChange = useCallback( + (option: TGroupByOption) => { + setSelected(option); + onChange(option); + }, + [onChange] + ); + + return ( + + ); +} diff --git a/apps/web/app/[locale]/reports/weekly-limit/components/members-select.tsx b/apps/web/app/[locale]/reports/weekly-limit/components/members-select.tsx new file mode 100644 index 000000000..1d2d8c730 --- /dev/null +++ b/apps/web/app/[locale]/reports/weekly-limit/components/members-select.tsx @@ -0,0 +1,55 @@ +import { useOrganizationTeams } from '@/app/hooks'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@components/ui/select'; +import { useTranslations } from 'next-intl'; +import { useCallback, useState } from 'react'; + +interface IProps { + onChange: (value: string) => void; +} + +/** + * MembersSelect component provides a dropdown selector for selecting team members. + * + * @component + * @param {IProps} props - The component props. + * @param {(value: string) => void} props.onChange - Function to handle changes in the selected member. + * + * @returns {JSX.Element} A dropdown for selecting a team member. + * + */ + +export function MembersSelect(props: IProps) { + const { onChange } = props; + const { activeTeam } = useOrganizationTeams(); + const [selected, setSelected] = useState('all'); + const t = useTranslations(); + const handleChange = useCallback( + (option: string) => { + setSelected(option); + onChange(option); + }, + [onChange] + ); + + 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 new file mode 100644 index 000000000..7b8349e6c --- /dev/null +++ b/apps/web/app/[locale]/reports/weekly-limit/components/time-report-table.tsx @@ -0,0 +1,55 @@ +import { ITimeLimitReport } from '@/app/interfaces/ITimeLimits'; +import { DataTableWeeklyLimits } from './data-table'; +import moment from 'moment'; +import { DEFAULT_WORK_HOURS_PER_DAY } from '@/app/constants'; + +interface IProps { + report: ITimeLimitReport; + displayMode: 'Week' | 'Day'; + organizationLimits: { [key: string]: number }; +} + +/** + * Renders a time report table displaying employee time tracking data. + * + * @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 {{ [key: string]: number }} props.organizationLimits - Contains organizational limits for time usage, specified by mode. + * + * @returns {JSX.Element} A formatted report table showing time usage and limits. + */ +export const TimeReportTable = ({ report, displayMode, organizationLimits }: IProps) => ( +
+
+

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

+
+
+ { + const limit = item.limit || organizationLimits[displayMode] || DEFAULT_WORK_HOURS_PER_DAY; + const percentageUsed = (item.duration / limit) * 100; + const remaining = limit - item.duration; + + return { + member: item.employee.fullName, + limit, + percentageUsed, + timeSpent: item.duration, + remaining + }; + })} + /> +
+
+); diff --git a/apps/web/app/[locale]/reports/weekly-limit/page.tsx b/apps/web/app/[locale]/reports/weekly-limit/page.tsx new file mode 100644 index 000000000..ad326015e --- /dev/null +++ b/apps/web/app/[locale]/reports/weekly-limit/page.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useAuthenticateUser, useOrganizationTeams } from '@/app/hooks'; +import { withAuthentication } from '@/lib/app/authenticator'; +import { Breadcrumb, Paginate } from '@/lib/components'; +import { MainLayout } from '@/lib/layout'; +import { useEffect, useMemo, useState } from 'react'; +import { DatePickerWithRange } from './components/date-range-select'; +import { MembersSelect } from './components/members-select'; +import { GroupBySelect, TGroupByOption } from './components/group-by-select'; +import { ExportModeSelect } from './components/export-mode-select'; +import { getAccessTokenCookie, getOrganizationIdCookie, getTenantIdCookie } from '@/app/helpers'; +import { useTimeLimits } from '@/app/hooks/features/useTimeLimits'; +import { DateRange } from 'react-day-picker'; +import { endOfMonth, startOfMonth } from 'date-fns'; +import moment from 'moment'; +import { usePagination } from '@/app/hooks/features/usePagination'; +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'; + +function WeeklyLimitReport() { + const { isTrackingEnabled } = useOrganizationTeams(); + const { user } = useAuthenticateUser(); + const [organization, setOrganization] = useState(); + const { timeLimitsReports, getTimeLimitsReport } = useTimeLimits(); + const organizationId = getOrganizationIdCookie(); + const tenantId = getTenantIdCookie(); + const [groupBy, setGroupBy] = useState('Day'); + const t = useTranslations(); + const breadcrumbPath = useMemo( + () => [ + { title: t('common.REPORTS'), href: '/' }, + { title: t('common.WEEKLY_LIMIT'), href: '/' } + ], + [t] + ); + // Default organization time limits + const organizationLimits = useMemo( + () => + organization && { + Day: organization.standardWorkHoursPerDay * 3600, + Week: organization.standardWorkHoursPerDay * 3600 * 5 + }, + [organization] + ); + const { activeTeam } = useOrganizationTeams(); + const [member, setMember] = useState('all'); + const [dateRange, setDateRange] = useState({ + from: startOfMonth(new Date()), + to: endOfMonth(new Date()) + }); + const accessToken = useMemo(() => getAccessTokenCookie(), []); + const timeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []); + const { total, onPageChange, itemsPerPage, itemOffset, endOffset, setItemsPerPage, currentItems } = + usePagination( + groupBy == 'Week' + ? timeLimitsReports.filter((report) => + moment(report.date).isSame(moment(report.date).startOf('isoWeek'), 'day') + ) + : timeLimitsReports + ); + + // Get the organization + useEffect(() => { + if (organizationId && tenantId) { + getUserOrganizationsRequest({ tenantId, userId: user?.id ?? '' }, accessToken ?? '').then((org) => { + setOrganization(org.data.items[0].organization); + }); + } + }, [organizationId, tenantId, user?.id, accessToken]); + + // Get Time limits data + useEffect(() => { + getTimeLimitsReport({ + organizationId, + tenantId, + employeeIds: [...(member === 'all' ? activeTeam?.members.map((m) => m.employeeId) ?? [] : [member])], + startDate: dateRange.from?.toISOString(), + endDate: dateRange.to?.toISOString(), + duration: groupBy != 'Member' ? groupBy.toLocaleLowerCase() : 'day', + timeZone + //TODO : add groupBy query (when it is ready in the API side) + }); + }, [ + activeTeam?.members, + dateRange.from, + dateRange.to, + getTimeLimitsReport, + groupBy, + member, + organizationId, + tenantId, + timeZone + ]); + + return ( + +
+
+ +
+
+
+
+

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

+
+ setMember(memberId)} /> + setDateRange(rangeDate)} + /> + console.log(value)} /> +
+
+
+ {t('common.GROUP_BY')}: + 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')) { + return ( + + ); + } else { + return null; + } + } else { + return ( + + ); + } + })} +
+
+ +
+ + ); +} + +export default withAuthentication(WeeklyLimitReport, { displayName: 'WeeklyLimitReport' }); diff --git a/apps/web/app/constants.ts b/apps/web/app/constants.ts index 016df0a8b..5df3d6b98 100644 --- a/apps/web/app/constants.ts +++ b/apps/web/app/constants.ts @@ -342,3 +342,5 @@ export const statusOptions = [ { value: 'Pending', label: 'Pending' }, { value: 'Rejected', label: 'Rejected' } ]; + +export const DEFAULT_WORK_HOURS_PER_DAY = 8; diff --git a/apps/web/app/hooks/features/useTimeLimits.ts b/apps/web/app/hooks/features/useTimeLimits.ts new file mode 100644 index 000000000..7e8ced617 --- /dev/null +++ b/apps/web/app/hooks/features/useTimeLimits.ts @@ -0,0 +1,32 @@ +import { timeLimitsAtom } from '@/app/stores/time-limits'; +import { useAtom } from 'jotai'; +import { useQuery } from '../useQuery'; +import { getTimeLimitsReportAPI } from '@/app/services/client/api/activity/time-limits'; +import { useCallback } from 'react'; +import { IGetTimeLimitReport } from '@/app/interfaces/ITimeLimits'; + +export function useTimeLimits() { + const [timeLimitsReports, setTimeLimitsReport] = useAtom(timeLimitsAtom); + + const { queryCall: getTimeLimitsReportQueryCall, loading: getTimeLimitReportLoading } = + useQuery(getTimeLimitsReportAPI); + + const getTimeLimitsReport = useCallback( + async (data: IGetTimeLimitReport) => { + try { + const res = await getTimeLimitsReportQueryCall(data); + + setTimeLimitsReport(res.data); + } catch (error) { + console.error(error); + } + }, + [getTimeLimitsReportQueryCall, setTimeLimitsReport] + ); + + return { + getTimeLimitReportLoading, + getTimeLimitsReport, + timeLimitsReports + }; +} diff --git a/apps/web/app/interfaces/IOrganization.ts b/apps/web/app/interfaces/IOrganization.ts index 3fe2ec2ee..b0d140eaf 100644 --- a/apps/web/app/interfaces/IOrganization.ts +++ b/apps/web/app/interfaces/IOrganization.ts @@ -66,6 +66,7 @@ export interface IOrganization { convertAcceptedEstimates: any; daysUntilDue: any; contactId: string; + standardWorkHoursPerDay: number; } export type IUserOrganization = Pick & { @@ -73,6 +74,7 @@ export type IUserOrganization = Pick(`/timesheet/time-log/time-limit?${query}`); +} diff --git a/apps/web/app/services/server/requests/organization.ts b/apps/web/app/services/server/requests/organization.ts index a30934f46..25aed615c 100644 --- a/apps/web/app/services/server/requests/organization.ts +++ b/apps/web/app/services/server/requests/organization.ts @@ -19,10 +19,16 @@ export function createOrganizationRequest(datas: IOrganizationCreate, bearerToke * @returns A promise resolving to a pagination response of user organizations. * @throws Error if required parameters are missing or invalid. */ -export function getUserOrganizationsRequest({ tenantId, userId }: { - tenantId: string; - userId: string -}, bearerToken: string) { +export function getUserOrganizationsRequest( + { + tenantId, + userId + }: { + tenantId: string; + userId: string; + }, + bearerToken: string +) { if (!tenantId || !userId || !bearerToken) { throw new Error('Tenant ID, User ID, and Bearer token are required'); // Validate required parameters } @@ -34,7 +40,12 @@ export function getUserOrganizationsRequest({ tenantId, userId }: { query.append('where[tenantId]', tenantId); // If there are relations, add them to the query - const relations: string[] = []; + const relations: string[] = [ + 'organization', + 'organization.contact', + 'organization.featureOrganizations', + 'organization.featureOrganizations.feature' + ]; // Append each relation to the query string relations.forEach((relation, index) => { query.append(`relations[${index}]`, relation); diff --git a/apps/web/app/stores/time-limits.ts b/apps/web/app/stores/time-limits.ts new file mode 100644 index 000000000..291363ba8 --- /dev/null +++ b/apps/web/app/stores/time-limits.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai'; +import { ITimeLimitReport } from '../interfaces/ITimeLimits'; + +export const timeLimitsAtom = atom([]); diff --git a/apps/web/components/app-sidebar.tsx b/apps/web/components/app-sidebar.tsx index 8c304672e..edd6ac2d4 100644 --- a/apps/web/components/app-sidebar.tsx +++ b/apps/web/components/app-sidebar.tsx @@ -221,7 +221,7 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { { title: t('sidebar.WEEKLY_LIMIT'), label: 'weekly-limit', - url: '#' + url: '/reports/weekly-limit' }, { title: t('sidebar.ACTUAL_AND_EXPECTED_HOURS'), diff --git a/apps/web/lib/settings/page-dropdown.tsx b/apps/web/lib/settings/page-dropdown.tsx index ae21585b4..2ec58e82e 100644 --- a/apps/web/lib/settings/page-dropdown.tsx +++ b/apps/web/lib/settings/page-dropdown.tsx @@ -1,11 +1,12 @@ 'use client'; -import { Dropdown } from 'lib/components'; import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { IPagination } from '@app/interfaces/IPagination'; import { clsxm } from '@app/utils'; import { PaginationItems, mappaginationItems } from './page-items'; +import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover'; +import { ChevronDownIcon } from 'lucide-react'; export const PaginationDropdown = ({ setValue, @@ -32,8 +33,8 @@ export const PaginationDropdown = ({ } ]); - const items: any = useMemo(() => mappaginationItems(paginationList), [paginationList]); - + const items: PaginationItems[] = useMemo(() => mappaginationItems(paginationList), [paginationList]); + const [open, setOpen] = useState(false); const [paginationItem, setPaginationItem] = useState(); const onChangeActiveTeam = useCallback( @@ -66,17 +67,33 @@ export const PaginationDropdown = ({ return ( <> - + + setOpen(!open)} + className={clsxm( + 'input-border', + 'w-full flex justify-between rounded-xl px-3 py-2 text-sm items-center', + 'font-normal outline-none', + 'py-0 font-medium h-[45px] w-[145px] z-10 outline-none dark:bg-dark--theme-light' + )} + > + {paginationItem?.selectedLabel || (paginationItem?.Label && )}{' '} + + + {items.map((Item, index) => ( +
onChangeActiveTeam(Item)} key={Item.key ? Item.key : index}> + +
+ ))} +
+
); }; diff --git a/apps/web/locales/ar.json b/apps/web/locales/ar.json index 166dfd0c0..cec448f14 100644 --- a/apps/web/locales/ar.json +++ b/apps/web/locales/ar.json @@ -261,7 +261,20 @@ "CLEAR_FILTER": "مسح الفلتر", "CLEAR": "مسح", "APPLY_FILTER": "تطبيق الفلتر", - "CREATE_NEW": "إنشاء جديد" + "CREATE_NEW": "إنشاء جديد", + "NO_RESULT": "لا توجد نتائج", + "PICK_A_DATE": "اختر تاريخًا", + "THIS_WEEK": "هذا الأسبوع", + "LAST_WEEK": "الأسبوع الماضي", + "THIS_MONTH": "هذا الشهر", + "LAST_MONTH": "الشهر الماضي", + "LAST_TWO_WEEKS": "آخر أسبوعين", + "DATE": "التاريخ", + "WEEK": "الأسبوع", + "GROUP_BY": "تجميع حسب", + "DAILY_LIMIT": "الحد اليومي", + "WEEKLY_LIMIT": "الحد الأسبوعي", + "REPORTS": "التقارير" }, "sidebar": { "DASHBOARD": "لوحة التحكم", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "لقد تم حذفك من الفريق" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "الوقت المستغرق", + "LIMIT": "الحد", + "PERCENTAGE_USED": "النسبة المستخدمة", + "REMAINING": "المتبقي", + "GROUP_BY": "تجميع حسب", + "DAILY_LIMIT": "الحد اليومي", + "WEEKLY_LIMIT": "الحد الأسبوعي" + }, "home": { "BREADCRUMB": "[\"لوحة التحكم\"]", "SENT_EMAIL_VERIFICATION": "تم إرسال تحقق الرمز إلى بريدك الإلكتروني", diff --git a/apps/web/locales/bg.json b/apps/web/locales/bg.json index 8ed9cd5d7..ce2a85668 100644 --- a/apps/web/locales/bg.json +++ b/apps/web/locales/bg.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Изчисти филтъра", "CLEAR": "Изчисти", "APPLY_FILTER": "Приложи филтър", - "CREATE_NEW": "Създай нов" + "CREATE_NEW": "Създай нов", + "NO_RESULT": "Няма резултати", + "PICK_A_DATE": "Изберете дата", + "THIS_WEEK": "Тази седмица", + "LAST_WEEK": "Миналата седмица", + "THIS_MONTH": "Този месец", + "LAST_MONTH": "Миналия месец", + "LAST_TWO_WEEKS": "Последните две седмици", + "DATE": "Дата", + "WEEK": "Седмица", + "GROUP_BY": "Групирай по", + "DAILY_LIMIT": "Дневен лимит", + "WEEKLY_LIMIT": "Седмичен лимит", + "REPORTS": "Доклади" }, "hotkeys": { "HELP": "Помощ", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Бяхте изтрит от екипа" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Изразходвано време", + "LIMIT": "Лимит", + "PERCENTAGE_USED": "Процент използван", + "REMAINING": "Оставащо", + "GROUP_BY": "Групирай по", + "DAILY_LIMIT": "Дневен лимит", + "WEEKLY_LIMIT": "Седмичен лимит" + }, "home": { "BREADCRUMB": "[\"Табло\"]", "SENT_EMAIL_VERIFICATION": "Потвърждаващият код беше изпратен до имейла ви", diff --git a/apps/web/locales/de.json b/apps/web/locales/de.json index 1fb17dfb1..38a13ed56 100644 --- a/apps/web/locales/de.json +++ b/apps/web/locales/de.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Filter löschen", "CLEAR": "Löschen", "APPLY_FILTER": "Filter anwenden", - "CREATE_NEW": "Neu erstellen" + "CREATE_NEW": "Neu erstellen", + "NO_RESULT": "Kein Ergebnis", + "PICK_A_DATE": "Datum auswählen", + "THIS_WEEK": "Diese Woche", + "LAST_WEEK": "Letzte Woche", + "THIS_MONTH": "Diesen Monat", + "LAST_MONTH": "Letzten Monat", + "LAST_TWO_WEEKS": "Letzte zwei Wochen", + "DATE": "Datum", + "WEEK": "Woche", + "GROUP_BY": "Gruppieren nach", + "DAILY_LIMIT": "Tägliches Limit", + "WEEKLY_LIMIT": "Wöchentliches Limit", + "REPORTS": "Berichte" }, "hotkeys": { "HELP": "Hilfe", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Du wurdest aus dem Team entfernt" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Verbrachte Zeit", + "LIMIT": "Grenze", + "PERCENTAGE_USED": "Prozent verwendet", + "REMAINING": "Verbleibend", + "GROUP_BY": "Gruppieren nach", + "DAILY_LIMIT": "Tägliches Limit", + "WEEKLY_LIMIT": "Wöchentliches Limit" + }, "home": { "BREADCRUMB": "[\"Dashboard\"]", "SENT_EMAIL_VERIFICATION": "Verifizierungscode wurde an Ihre E-Mail gesendet", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index a8b7419c8..180cfd86b 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Clear Filter", "CLEAR": "Clear", "APPLY_FILTER": "Apply Filter", - "CREATE_NEW": "Create New" + "CREATE_NEW": "Create New", + "NO_RESULT": "No result", + "PICK_A_DATE": "Pick a date", + "THIS_WEEK": "This week", + "LAST_WEEK": "Last week", + "THIS_MONTH": "This month", + "LAST_MONTH": "Last month", + "LAST_TWO_WEEKS": "Last two weeks", + "DATE": "Date", + "WEEK": "Week", + "GROUP_BY": "Group by", + "DAILY_LIMIT": "Daily limit", + "WEEKLY_LIMIT": "Weekly limit", + "REPORTS": "Reports" }, "hotkeys": { "HELP": "Help", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "You have been deleted from the team" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Time spent", + "LIMIT": "Limit", + "PERCENTAGE_USED": "Percentage used", + "REMAINING": "Remaining", + "GROUP_BY": "Group by", + "DAILY_LIMIT": "Daily limit", + "WEEKLY_LIMIT": "Weekly limit" + }, "home": { "BREADCRUMB": "[\"Dashboard\"]", "SENT_EMAIL_VERIFICATION": "Code Verification has been sent to your email", diff --git a/apps/web/locales/es.json b/apps/web/locales/es.json index c5908f186..78196ca87 100644 --- a/apps/web/locales/es.json +++ b/apps/web/locales/es.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Borrar filtro", "CLEAR": "Borrar", "APPLY_FILTER": "Aplicar filtro", - "CREATE_NEW": "Crear nuevo" + "CREATE_NEW": "Crear nuevo", + "NO_RESULT": "Sin resultados", + "PICK_A_DATE": "Selecciona una fecha", + "THIS_WEEK": "Esta semana", + "LAST_WEEK": "La semana pasada", + "THIS_MONTH": "Este mes", + "LAST_MONTH": "El mes pasado", + "LAST_TWO_WEEKS": "Las últimas dos semanas", + "DATE": "Fecha", + "WEEK": "Semana", + "GROUP_BY": "Agrupar por", + "DAILY_LIMIT": "Límite diario", + "WEEKLY_LIMIT": "Límite semanal", + "REPORTS": "Informes" }, "hotkeys": { "HELP": "Ayuda", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Has sido eliminado del equipo" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Tiempo invertido", + "LIMIT": "Límite", + "PERCENTAGE_USED": "Porcentaje usado", + "REMAINING": "Restante", + "GROUP_BY": "Agrupar por", + "DAILY_LIMIT": "Límite diario", + "WEEKLY_LIMIT": "Límite semanal" + }, "home": { "BREADCRUMB": "[\"Tablero\"]", "SENT_EMAIL_VERIFICATION": "Se ha enviado la verificación de código a tu correo electrónico", diff --git a/apps/web/locales/fr.json b/apps/web/locales/fr.json index 47c2ca5b8..f603ed553 100644 --- a/apps/web/locales/fr.json +++ b/apps/web/locales/fr.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Effacer le filtre", "CLEAR": "Effacer", "APPLY_FILTER": "Appliquer le filtre", - "CREATE_NEW": "Créer nouveau" + "CREATE_NEW": "Créer nouveau", + "NO_RESULT": "Aucun résultat", + "PICK_A_DATE": "Choisissez une date", + "THIS_WEEK": "Cette semaine", + "LAST_WEEK": "La semaine dernière", + "THIS_MONTH": "Ce mois-ci", + "LAST_MONTH": "Le mois dernier", + "LAST_TWO_WEEKS": "Les deux dernières semaines", + "DATE": "Date", + "WEEK": "Semaine", + "GROUP_BY": "Regrouper par", + "DAILY_LIMIT": "Limite quotidienne", + "WEEKLY_LIMIT": "Limite hebdomadaire", + "REPORTS": "Rapports" }, "hotkeys": { "HELP": "Aide", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Vous avez été supprimé de l'équipe" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Temps passé", + "LIMIT": "Limite", + "PERCENTAGE_USED": "Pourcentage utilisé", + "REMAINING": "Restant", + "GROUP_BY": "Regrouper par", + "DAILY_LIMIT": "Limite quotidienne", + "WEEKLY_LIMIT": "Limite hebdomadaire" + }, "home": { "BREADCRUMB": "[\"Tableau de bord\"]", "SENT_EMAIL_VERIFICATION": "Le code de vérification a été envoyé à votre adresse e-mail", diff --git a/apps/web/locales/he.json b/apps/web/locales/he.json index 55664ac50..a1221c365 100644 --- a/apps/web/locales/he.json +++ b/apps/web/locales/he.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "נקה סינון", "CLEAR": "נקה", "APPLY_FILTER": "החל סינון", - "CREATE_NEW": "צור חדש" + "CREATE_NEW": "צור חדש", + "NO_RESULT": "אין תוצאות", + "PICK_A_DATE": "בחר תאריך", + "THIS_WEEK": "השבוע", + "LAST_WEEK": "שבוע שעבר", + "THIS_MONTH": "החודש", + "LAST_MONTH": "חודש שעבר", + "LAST_TWO_WEEKS": "שבועיים אחרונים", + "DATE": "תאריך", + "WEEK": "שבוע", + "GROUP_BY": "קבץ לפי", + "DAILY_LIMIT": "הגבלה יומית", + "WEEKLY_LIMIT": "הגבלה שבועית", + "REPORTS": "דוחות" }, "hotkeys": { "HELP": "עזרה", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "נמחקת מהקבוצה" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "הזמן שהושקע", + "LIMIT": "מגבלה", + "PERCENTAGE_USED": "אחוז בשימוש", + "REMAINING": "נותר", + "GROUP_BY": "קבץ לפי", + "DAILY_LIMIT": "הגבלה יומית", + "WEEKLY_LIMIT": "הגבלה שבועית" + }, "home": { "BREADCRUMB": "[\"לוח מחוונים\"]", "SENT_EMAIL_VERIFICATION": "אימות קוד נשלח לאימייל שלך", diff --git a/apps/web/locales/it.json b/apps/web/locales/it.json index ac7482ff5..e7d7ab32c 100644 --- a/apps/web/locales/it.json +++ b/apps/web/locales/it.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Cancella filtro", "CLEAR": "Cancella", "APPLY_FILTER": "Applica filtro", - "CREATE_NEW": "Crea nuovo" + "CREATE_NEW": "Crea nuovo", + "NO_RESULT": "Nessun risultato", + "PICK_A_DATE": "Seleziona una data", + "THIS_WEEK": "Questa settimana", + "LAST_WEEK": "La settimana scorsa", + "THIS_MONTH": "Questo mese", + "LAST_MONTH": "Il mese scorso", + "LAST_TWO_WEEKS": "Ultime due settimane", + "DATE": "Data", + "WEEK": "Settimana", + "GROUP_BY": "Raggruppa per", + "DAILY_LIMIT": "Limite giornaliero", + "WEEKLY_LIMIT": "Limite settimanale", + "REPORTS": "Report" }, "hotkeys": { "HELP": "Aiuto", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Sei stato rimosso dal team" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Tempo trascorso", + "LIMIT": "Limite", + "PERCENTAGE_USED": "Percentuale usata", + "REMAINING": "Rimanente", + "GROUP_BY": "Raggruppa per", + "DAILY_LIMIT": "Limite giornaliero", + "WEEKLY_LIMIT": "Limite settimanale" + }, "home": { "BREADCRUMB": "[\"Pannello di controllo\"]", "SENT_EMAIL_VERIFICATION": "Il codice di verifica è stato inviato alla tua email", diff --git a/apps/web/locales/nl.json b/apps/web/locales/nl.json index 72f376d36..5bc5d834c 100644 --- a/apps/web/locales/nl.json +++ b/apps/web/locales/nl.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Filter wissen", "CLEAR": "Wissen", "APPLY_FILTER": "Filter toepassen", - "CREATE_NEW": "Nieuwe maken" + "CREATE_NEW": "Nieuwe maken", + "NO_RESULT": "Geen resultaat", + "PICK_A_DATE": "Kies een datum", + "THIS_WEEK": "Deze week", + "LAST_WEEK": "Vorige week", + "THIS_MONTH": "Deze maand", + "LAST_MONTH": "Vorige maand", + "LAST_TWO_WEEKS": "Laatste twee weken", + "DATE": "Datum", + "WEEK": "Week", + "GROUP_BY": "Groeperen op", + "DAILY_LIMIT": "Daglimiet", + "WEEKLY_LIMIT": "Weeklimiet", + "REPORTS": "Rapporten" }, "hotkeys": { "HELP": "Help", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Je bent uit het team verwijderd" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Bestede tijd", + "LIMIT": "Limiet", + "PERCENTAGE_USED": "Percentage gebruikt", + "REMAINING": "Resterend", + "GROUP_BY": "Groeperen op", + "DAILY_LIMIT": "Daglimiet", + "WEEKLY_LIMIT": "Weeklimiet" + }, "home": { "BREADCRUMB": "[\"Dashboard\"]", "SENT_EMAIL_VERIFICATION": "Codeverificatie is verzonden naar uw e-mailadres", diff --git a/apps/web/locales/pl.json b/apps/web/locales/pl.json index 709a8ab61..ce7d15b24 100644 --- a/apps/web/locales/pl.json +++ b/apps/web/locales/pl.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Wyczyść filtr", "CLEAR": "Wyczyść", "APPLY_FILTER": "Zastosuj filtr", - "CREATE_NEW": "Utwórz nowy" + "CREATE_NEW": "Utwórz nowy", + "NO_RESULT": "Brak wyników", + "PICK_A_DATE": "Wybierz datę", + "THIS_WEEK": "Ten tydzień", + "LAST_WEEK": "Ostatni tydzień", + "THIS_MONTH": "Ten miesiąc", + "LAST_MONTH": "Ostatni miesiąc", + "LAST_TWO_WEEKS": "Ostatnie dwa tygodnie", + "DATE": "Data", + "WEEK": "Tydzień", + "GROUP_BY": "Grupuj według", + "DAILY_LIMIT": "Dzienne ograniczenie", + "WEEKLY_LIMIT": "Tygodniowe ograniczenie", + "REPORTS": "Raporty" }, "hotkeys": { "HELP": "Pomoc", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Zostałeś usunięty z zespołu" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Czas spędzony", + "LIMIT": "Limit", + "PERCENTAGE_USED": "Procent użyty", + "REMAINING": "Pozostało", + "GROUP_BY": "Grupuj według", + "DAILY_LIMIT": "Dzienne ograniczenie", + "WEEKLY_LIMIT": "Tygodniowe ograniczenie" + }, "home": { "BREADCRUMB": "[\"Panel\"]", "SENT_EMAIL_VERIFICATION": "Kod weryfikacyjny został wysłany na Twój adres e-mail", diff --git a/apps/web/locales/pt.json b/apps/web/locales/pt.json index 702a1254d..bacf7d021 100644 --- a/apps/web/locales/pt.json +++ b/apps/web/locales/pt.json @@ -280,7 +280,20 @@ "CLEAR_FILTER": "Limpar filtro", "CLEAR": "Limpar", "APPLY_FILTER": "Aplicar filtro", - "CREATE_NEW": "Criar novo" + "CREATE_NEW": "Criar novo", + "NO_RESULT": "Sem resultados", + "PICK_A_DATE": "Escolha uma data", + "THIS_WEEK": "Esta semana", + "LAST_WEEK": "Semana passada", + "THIS_MONTH": "Este mês", + "LAST_MONTH": "Mês passado", + "LAST_TWO_WEEKS": "Últimas duas semanas", + "DATE": "Data", + "WEEK": "Semana", + "GROUP_BY": "Agrupar por", + "DAILY_LIMIT": "Limite diário", + "WEEKLY_LIMIT": "Limite semanal", + "REPORTS": "Relatórios" }, "hotkeys": { "HELP": "Ajuda", @@ -302,6 +315,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Você foi removido da equipe" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Tempo gasto", + "LIMIT": "Limite", + "PERCENTAGE_USED": "Porcentagem usada", + "REMAINING": "Restante", + "GROUP_BY": "Agrupar por", + "DAILY_LIMIT": "Limite diário", + "WEEKLY_LIMIT": "Limite semanal" + }, "home": { "BREADCRUMB": "[\"Painel de Controle\"]", "SENT_EMAIL_VERIFICATION": "O código de verificação foi enviado para o seu e-mail", diff --git a/apps/web/locales/ru.json b/apps/web/locales/ru.json index 92dae73ff..1819cc5f6 100644 --- a/apps/web/locales/ru.json +++ b/apps/web/locales/ru.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "Очистить фильтр", "CLEAR": "Очистить", "APPLY_FILTER": "Применить фильтр", - "CREATE_NEW": "Создать новый" + "CREATE_NEW": "Создать новый", + "NO_RESULT": "Нет результатов", + "PICK_A_DATE": "Выберите дату", + "THIS_WEEK": "На этой неделе", + "LAST_WEEK": "На прошлой неделе", + "THIS_MONTH": "В этом месяце", + "LAST_MONTH": "В прошлом месяце", + "LAST_TWO_WEEKS": "Последние две недели", + "DATE": "Дата", + "WEEK": "Неделя", + "GROUP_BY": "Группировать по", + "DAILY_LIMIT": "Дневной лимит", + "WEEKLY_LIMIT": "Недельный лимит", + "REPORTS": "Отчеты" }, "hotkeys": { "HELP": "Помощь", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "Вы были удалены из команды" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "Затраченное время", + "LIMIT": "Предел", + "PERCENTAGE_USED": "Использованный процент", + "REMAINING": "Осталось", + "GROUP_BY": "Группировать по", + "DAILY_LIMIT": "Дневной лимит", + "WEEKLY_LIMIT": "Недельный лимит" + }, "home": { "BREADCRUMB": "[\"панель приборов\"]", "SENT_EMAIL_VERIFICATION": "Код подтверждения был отправлен на ваш электронный адрес", diff --git a/apps/web/locales/zh.json b/apps/web/locales/zh.json index 67a2e23c2..2b4f4b63a 100644 --- a/apps/web/locales/zh.json +++ b/apps/web/locales/zh.json @@ -279,7 +279,20 @@ "CLEAR_FILTER": "清除筛选", "CLEAR": "清除", "APPLY_FILTER": "应用筛选", - "CREATE_NEW": "创建新的" + "CREATE_NEW": "创建新的", + "NO_RESULT": "没有结果", + "PICK_A_DATE": "选择日期", + "THIS_WEEK": "本周", + "LAST_WEEK": "上周", + "THIS_MONTH": "本月", + "LAST_MONTH": "上个月", + "LAST_TWO_WEEKS": "过去两周", + "DATE": "日期", + "WEEK": "周", + "GROUP_BY": "分组依据", + "DAILY_LIMIT": "每日限制", + "WEEKLY_LIMIT": "每周限制", + "REPORTS": "报告" }, "hotkeys": { "HELP": "帮助", @@ -301,6 +314,15 @@ "ALERT_USER_DELETED_FROM_TEAM": "您已从团队中删除" }, "pages": { + "timeLimitReport": { + "TIME_SPENT": "花费时间", + "LIMIT": "限制", + "PERCENTAGE_USED": "使用百分比", + "REMAINING": "剩余", + "GROUP_BY": "分组依据", + "DAILY_LIMIT": "每日限制", + "WEEKLY_LIMIT": "每周限制" + }, "home": { "BREADCRUMB": "[\"仪表板\"]", "SENT_EMAIL_VERIFICATION": "验证码已发送至您的电子邮箱", From ebe2aeed3fcbccd7dd7cd1f539fdd87e59e3cba5 Mon Sep 17 00:00:00 2001 From: syns2191 Date: Sun, 1 Dec 2024 19:56:32 +0700 Subject: [PATCH 3/5] fix: naming interface runtime server config --- apps/web/app/interfaces/IRuntimeServerConfig.ts | 2 +- apps/web/app/services/server/requests/desktop-source.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/interfaces/IRuntimeServerConfig.ts b/apps/web/app/interfaces/IRuntimeServerConfig.ts index cff07e845..1dad7010c 100644 --- a/apps/web/app/interfaces/IRuntimeServerConfig.ts +++ b/apps/web/app/interfaces/IRuntimeServerConfig.ts @@ -1,4 +1,4 @@ -export interface ServerRuntimeConfig { +export interface IServerRuntimeConfig { GAUZY_API_SERVER_URL?: string; NEXT_PUBLIC_GAUZY_API_SERVER_URL?: string; [key: string]: any; diff --git a/apps/web/app/services/server/requests/desktop-source.ts b/apps/web/app/services/server/requests/desktop-source.ts index 92d5f2c09..be0633e02 100644 --- a/apps/web/app/services/server/requests/desktop-source.ts +++ b/apps/web/app/services/server/requests/desktop-source.ts @@ -1,8 +1,8 @@ import getConfig from 'next/config'; -import { ServerRuntimeConfig } from '@app/interfaces/IRuntimeServerConfig'; +import { IServerRuntimeConfig } from '@app/interfaces/IRuntimeServerConfig'; import { GAUZY_API_SERVER_URL, GAUZY_API_BASE_SERVER_URL } from '@app/constants'; -export function getDesktopConfig(): Partial { +export function getDesktopConfig(): Partial { try { const { serverRuntimeConfig } = getConfig(); return serverRuntimeConfig; From b31cda6fda8054a672684525cda1784e8bafa141 Mon Sep 17 00:00:00 2001 From: Ruslan Konviser Date: Wed, 4 Dec 2024 19:25:08 +0100 Subject: [PATCH 4/5] chore: desktop apps --- .github/workflows/desktop-server-api.apps.yml | 4 ++-- .github/workflows/desktop-server-web.apps.yml | 4 ++-- .github/workflows/desktop.apps.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-server-api.apps.yml b/.github/workflows/desktop-server-api.apps.yml index 34ae2c2c4..2c79f5c00 100644 --- a/.github/workflows/desktop-server-api.apps.yml +++ b/.github/workflows/desktop-server-api.apps.yml @@ -102,7 +102,7 @@ jobs: strategy: matrix: - os: [macos-12] + os: [macos-latest] steps: - name: Check out Git repository @@ -118,7 +118,7 @@ jobs: cache: 'yarn' - name: Fix node-gyp and Python - run: python3 -m pip install packaging setuptools + run: python3 -m pip install --break-system-packages packaging setuptools - name: Install latest version of NPM run: 'sudo npm install -g npm@9' diff --git a/.github/workflows/desktop-server-web.apps.yml b/.github/workflows/desktop-server-web.apps.yml index 1c5677afa..d771c3a28 100644 --- a/.github/workflows/desktop-server-web.apps.yml +++ b/.github/workflows/desktop-server-web.apps.yml @@ -102,7 +102,7 @@ jobs: strategy: matrix: - os: [macos-12] + os: [macos-latest] steps: - name: Check out Git repository @@ -115,7 +115,7 @@ jobs: cache: 'yarn' - name: Fix node-gyp and Python - run: python3 -m pip install packaging setuptools + run: python3 -m pip install --break-system-packages packaging setuptools - name: Install latest version of NPM run: 'sudo npm install -g npm@9' diff --git a/.github/workflows/desktop.apps.yml b/.github/workflows/desktop.apps.yml index 6fe773c85..0d12db247 100644 --- a/.github/workflows/desktop.apps.yml +++ b/.github/workflows/desktop.apps.yml @@ -102,7 +102,7 @@ jobs: strategy: matrix: - os: [macos-12] + os: [macos-latest] steps: - name: Check out Git repository @@ -118,7 +118,7 @@ jobs: cache: 'yarn' - name: Fix node-gyp and Python - run: python3 -m pip install packaging setuptools + run: python3 -m pip install --break-system-packages packaging setuptools - name: Install latest version of NPM run: 'sudo npm install -g npm@9' From 88fc30c1eb9ace3df270fee783af787dec6527b0 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Wed, 4 Dec 2024 20:28:46 +0200 Subject: [PATCH 5/5] [Feat]: Add MonthlyCalendarDataView and WeeklyCalendarDataView for displaying timesheet (#3388) * feat: Add MonthlyCalendarDataView component with dynamic calendar generation and timesheet grouping * fix: deepscan * fix:coderabbitai * fix:coderabbitai * feat(calendar): add weekly view with navigation * feat: coderabbitai * refactor: Combine Monthly and Weekly views into a single component * feat:feat: add day translation keys for localization --- .../[memberId]/components/CalendarView.tsx | 167 +++++++++++++++--- .../components/CompactTimesheetComponent.tsx | 52 +++--- .../[memberId]/components/EditTaskModal.tsx | 2 +- .../components/MonthlyTimesheetCalendar.tsx | 147 +++++++++++++++ .../[memberId]/components/TimesheetView.tsx | 2 +- .../components/WeeklyTimesheetCalendar.tsx | 150 ++++++++++++++++ .../[locale]/timesheet/[memberId]/page.tsx | 12 +- apps/web/app/hooks/features/useTimesheet.ts | 19 +- .../calendar/table-time-sheet.tsx | 8 +- apps/web/lib/features/task/task-displays.tsx | 1 + apps/web/locales/ar.json | 9 + apps/web/locales/bg.json | 9 + apps/web/locales/de.json | 9 + apps/web/locales/en.json | 9 + apps/web/locales/es.json | 9 + apps/web/locales/fr.json | 9 + apps/web/locales/he.json | 9 + apps/web/locales/it.json | 9 + apps/web/locales/nl.json | 9 + apps/web/locales/pl.json | 9 + apps/web/locales/pt.json | 9 + apps/web/locales/ru.json | 9 + apps/web/locales/zh.json | 9 + 23 files changed, 619 insertions(+), 58 deletions(-) create mode 100644 apps/web/app/[locale]/timesheet/[memberId]/components/MonthlyTimesheetCalendar.tsx create mode 100644 apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx index 584cf19df..360aced88 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/CalendarView.tsx @@ -1,22 +1,47 @@ import { GroupedTimesheet, useTimesheet } from "@/app/hooks/features/useTimesheet"; -import { clsxm } from "@/app/utils"; import { statusColor } from "@/lib/components"; import { DisplayTimeForTimesheet, TaskNameInfoDisplay, TotalDurationByDate, TotalTimeDisplay } from "@/lib/features"; -import { AccordionContent, AccordionItem, AccordionTrigger } from "@components/ui/accordion"; -import { Accordion } from "@radix-ui/react-accordion"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@components/ui/accordion"; import { TranslationHooks, useTranslations } from "next-intl"; import React from "react"; -import { EmployeeAvatar } from "./CompactTimesheetComponent"; +import { EmployeeAvatar, ProjectLogo } from "./CompactTimesheetComponent"; import { formatDate } from "@/app/helpers"; -import { ClockIcon } from "lucide-react"; +import { ClockIcon, CodeSquareIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import MonthlyTimesheetCalendar from "./MonthlyTimesheetCalendar"; +import { useTimelogFilterOptions } from "@/app/hooks"; +import WeeklyTimesheetCalendar from "./WeeklyTimesheetCalendar"; +interface BaseCalendarDataViewProps { + data: GroupedTimesheet[]; + daysLabels?: string[]; + CalendarComponent: typeof MonthlyTimesheetCalendar | typeof WeeklyTimesheetCalendar; +} export function CalendarView({ data, loading }: { data?: GroupedTimesheet[], loading: boolean }) { const t = useTranslations(); + const { timesheetGroupByDays } = useTimelogFilterOptions(); + const defaultDaysLabels = [ + t("common.DAYS.sun"), + t("common.DAYS.mon"), + t("common.DAYS.tue"), + t("common.DAYS.wed"), + t("common.DAYS.thu"), + t("common.DAYS.fri"), + t("common.DAYS.sat") + ]; return ( -
+
{data ? ( data.length > 0 ? ( - + <> + {timesheetGroupByDays === 'Monthly' ? ( + + ) : timesheetGroupByDays === 'Weekly' ? ( + + ) : ( + + )} + ) : (

{t('pages.timesheet.NO_ENTRIES_FOUND')}

@@ -40,7 +65,7 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati {data?.map((plan, index) => { return
0 && status && + className="p-1 rounded" > + )}>
-
+
{status === 'DENIED' ? 'REJECTED' : status} @@ -97,10 +120,9 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati borderLeftColor: statusColor(status).border }} - className={clsxm( + className={cn( 'border-l-4 rounded-l flex flex-col p-2 gap-2 items-start space-x-4 h-[100px]', - )} - > + )}>
-
+
+ {task.project && } {task.project && task.project.name}
@@ -142,3 +165,107 @@ const CalendarDataView = ({ data, t }: { data?: GroupedTimesheet[], t: Translati
) } + +const BaseCalendarDataView = ({ data, daysLabels, CalendarComponent }: BaseCalendarDataViewProps) => { + const { getStatusTimesheet } = useTimesheet({}); + return ( + { + return <> + {plan ? ( + + {Object.entries(getStatusTimesheet(plan.tasks)).map(([status, rows]) => ( + rows.length > 0 && status && + +
+
+
+
+ + {status === 'DENIED' ? 'REJECTED' : status} + + ({rows.length}) +
+
+
+ + +
+
+
+ + {rows.map((task) => ( +
+
+
+ + {task.employee.fullName} +
+ +
+ +
+ {task.project && } + {task.project && task.project.name} +
+
+ ))} +
+
+ ))} +
+ ) : ( +
+ + No Data +
+ )} + + }} + /> + ); +}; + +const MonthlyCalendarDataView = (props: { data: GroupedTimesheet[], daysLabels?: string[] }) => ( + +); + +const WeeklyCalendarDataView = (props: { data: GroupedTimesheet[], daysLabels?: string[] }) => ( + +); diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx index 03f80d818..4a4f7c527 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/CompactTimesheetComponent.tsx @@ -1,27 +1,5 @@ import React from "react"; -export const EmployeeAvatar = ({ imageUrl }: { imageUrl: string }) => { - const [isLoading, setIsLoading] = React.useState(true); - - return ( -
- {isLoading && ( -
- -
- )} - Employee setIsLoading(false)} - onError={() => setIsLoading(false)} - /> -
- ); -}; - - const LoadingSpinner = ({ className }: { className?: string }) => ( ( > ); + +const ImageWithLoader = ({ imageUrl, alt, className = "w-6 h-6 rounded-full" }: + { imageUrl: string; alt: string; className?: string }) => { + const [isLoading, setIsLoading] = React.useState(true); + return ( +
+ {isLoading && ( +
+ +
+ )} + {alt} setIsLoading(false)} + onError={() => setIsLoading(false)} + /> +
+ ); +}; + +export const EmployeeAvatar = ({ imageUrl }: { imageUrl: string }) => ( + +); + + +export const ProjectLogo = ({ imageUrl }: { imageUrl: string }) => ( + +); diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx index 3daa25980..10a66b369 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/EditTaskModal.tsx @@ -117,7 +117,7 @@ export function EditTaskModal({ isOpen, closeModal, dataTimesheet }: IEditTaskMo valueKey: 'id', displayKey: 'name', element: 'Project', - defaultValue: dataTimesheet.project.name + defaultValue: 'name' }, ]; diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/MonthlyTimesheetCalendar.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/MonthlyTimesheetCalendar.tsx new file mode 100644 index 000000000..55f6daad5 --- /dev/null +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/MonthlyTimesheetCalendar.tsx @@ -0,0 +1,147 @@ +import React, { useMemo, useState, useCallback } from "react"; +import { format, addMonths, eachDayOfInterval, startOfMonth, endOfMonth, addDays, Locale } from "date-fns"; +import { GroupedTimesheet } from "@/app/hooks/features/useTimesheet"; +import { enGB } from 'date-fns/locale'; +import { cn } from "@/lib/utils"; +import { TotalDurationByDate } from "@/lib/features"; +import { formatDate } from "@/app/helpers"; + +type MonthlyCalendarDataViewProps = { + data?: GroupedTimesheet[]; + onDateClick?: (date: Date) => void; + renderDayContent?: (date: Date, plan?: GroupedTimesheet) => React.ReactNode; + locale?: Locale; + daysLabels?: string[]; + noDataText?: string; + classNames?: { + container?: string; + header?: string; + grid?: string; + day?: string; + noData?: string; + }; +}; + +const defaultDaysLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const generateFullCalendar = (currentMonth: Date) => { + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const startDate = addDays(monthStart, -monthStart.getDay()); + const endDate = addDays(monthEnd, 6 - monthEnd.getDay()); + return eachDayOfInterval({ start: startDate, end: endDate }); +}; + + +const MonthlyTimesheetCalendar: React.FC = ({ + data = [], + onDateClick, + renderDayContent, + locale = enGB, + daysLabels = defaultDaysLabels, + noDataText = "No Data", + classNames = {} +}) => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const calendarDates = useMemo(() => generateFullCalendar(currentMonth), [currentMonth]); + const groupedData = useMemo( + () => new Map(data.map((plan) => [format(new Date(plan.date), "yyyy-MM-dd"), plan])), + [data] + ); + + const handlePreviousMonth = useCallback(() => setCurrentMonth((prev) => addMonths(prev, -1)), []); + const handleNextMonth = useCallback(() => setCurrentMonth((prev) => addMonths(prev, 1)), []); + + return ( +
+ {/* Header */} +
+ +

+ {format(currentMonth, "MMMM yyyy", { locale: locale })} +

+ +
+ + {/* Grid */} +
+ {daysLabels.map((day) => ( +
{day}
+ ))} +
+ +
+ {calendarDates.map((date) => { + const formattedDate = format(date, "yyyy-MM-dd"); + const plan = groupedData.get(formattedDate); + return ( +
onDateClick?.(date)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onDateClick?.(date); + } + }} + > +
+ + {format(date, "dd MMM yyyy")} + +
+ Total{" : "} + {plan && } +
+
+ {renderDayContent ? ( + renderDayContent(date, plan) + ) : plan ? ( +
+ {plan.tasks.map((task) => ( +
+ {task.task?.title} +
+ ))} +
+ ) : ( +
+ {noDataText} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default MonthlyTimesheetCalendar; diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetView.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetView.tsx index ee72a1f79..18606f719 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetView.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetView.tsx @@ -18,7 +18,7 @@ export function TimesheetView({ data, loading }: { data?: GroupedTimesheet[]; lo if (data.length === 0) { return ( -
+

{t('pages.timesheet.NO_ENTRIES_FOUND')}

); diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx new file mode 100644 index 000000000..db2805f0b --- /dev/null +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/WeeklyTimesheetCalendar.tsx @@ -0,0 +1,150 @@ +import React, { useMemo, useState, useCallback } from "react"; +import { format, addDays, startOfWeek, endOfWeek, eachDayOfInterval, Locale } from "date-fns"; +import { enGB } from "date-fns/locale"; +import { cn } from "@/lib/utils"; +import { GroupedTimesheet } from "@/app/hooks/features/useTimesheet"; +import { TotalDurationByDate } from "@/lib/features"; +import { formatDate } from "@/app/helpers"; + +type WeeklyCalendarProps = { + data?: GroupedTimesheet[]; + onDateClick?: (date: Date) => void; + renderDayContent?: (date: Date, plan?: GroupedTimesheet) => React.ReactNode; + locale?: Locale; + daysLabels?: string[]; + noDataText?: string; + classNames?: { + container?: string; + header?: string; + grid?: string; + day?: string; + noData?: string; + }; +}; + +const defaultDaysLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const generateWeek = (currentDate: Date) => { + const weekStart = startOfWeek(currentDate, { weekStartsOn: 0 }); + const weekEnd = endOfWeek(currentDate, { weekStartsOn: 0 }); + return eachDayOfInterval({ start: weekStart, end: weekEnd }); +}; + +const WeeklyTimesheetCalendar: React.FC = ({ + data = [], + onDateClick, + renderDayContent, + locale = enGB, + daysLabels = defaultDaysLabels, + noDataText = "No Data", + classNames = {}, +}) => { + const [currentDate, setCurrentDate] = useState(new Date()); + + // Calculate the current week based on `currentDate` + const weekDates = useMemo(() => generateWeek(currentDate), [currentDate]); + + // Map data to the respective dates + const groupedData = useMemo( + () => new Map(data.map((plan) => [format(new Date(plan.date), "yyyy-MM-dd"), plan])), + [data] + ); + + // Handlers for navigation + const handlePreviousWeek = useCallback(() => setCurrentDate((prev) => addDays(prev, -7)), []); + const handleNextWeek = useCallback(() => setCurrentDate((prev) => addDays(prev, 7)), []); + + return ( +
+
+ +

+ {`Week of ${format(weekDates[0], "MMM d", { locale })} - ${format( + weekDates[6], + "MMM d, yyyy", + { locale } + )}`} +

+ +
+ +
+ {daysLabels.map((day) => ( +
{day}
+ ))} +
+ +
+ {weekDates.map((date) => { + const formattedDate = format(date, "yyyy-MM-dd"); + const plan = groupedData.get(formattedDate); + + return ( +
onDateClick?.(date)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onDateClick?.(date); + } + }} + > +
+ + {format(date, "dd MMM yyyy")} + +
+ Total{" : "} + {plan && ( + + )} +
+
+ {renderDayContent ? ( + renderDayContent(date, plan) + ) : plan ? ( +
+ {plan.tasks.map((task) => ( +
+ {task.task?.title} +
+ ))} +
+ ) : ( +
+ {noDataText} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default WeeklyTimesheetCalendar; diff --git a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx index 3eecb7d5f..ade889e9b 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/page.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/page.tsx @@ -39,6 +39,10 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb const { user } = useAuthenticateUser(); const [search, setSearch] = useState(''); const [filterStatus, setFilterStatus] = useLocalStorageState('timesheet-filter-status', 'All Tasks'); + const [timesheetNavigator, setTimesheetNavigator] = useLocalStorageState( + 'timesheet-viewMode', + 'ListView' + ); const [dateRange, setDateRange] = React.useState<{ from: Date | null; to: Date | null }>({ from: startOfDay(new Date()), @@ -46,7 +50,8 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb }); const { timesheet, statusTimesheet, loadingTimesheet } = useTimesheet({ startDate: dateRange.from ?? '', - endDate: dateRange.to ?? '' + endDate: dateRange.to ?? '', + timesheetViewMode: timesheetNavigator }); @@ -83,10 +88,7 @@ const TimeSheet = React.memo(function TimeSheetPage({ params }: { params: { memb const username = user?.name || user?.firstName || user?.lastName || user?.username; - const [timesheetNavigator, setTimesheetNavigator] = useLocalStorageState( - 'timesheet-viewMode', - 'ListView' - ); + const fullWidth = useAtomValue(fullWidthState); const { isTrackingEnabled, activeTeam } = useOrganizationTeams(); diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index 91e320c53..9add0836d 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -11,6 +11,7 @@ import { useTimelogFilterOptions } from './useTimelogFilterOptions'; interface TimesheetParams { startDate?: Date | string; endDate?: Date | string; + timesheetViewMode?: 'ListView' | 'CalendarView' } export interface GroupedTimesheet { @@ -90,6 +91,7 @@ const groupByMonth = createGroupingFunction(date => export function useTimesheet({ startDate, endDate, + timesheetViewMode }: TimesheetParams) { const { user } = useAuthenticateUser(); const [timesheet, setTimesheet] = useAtom(timesheetRapportState); @@ -262,14 +264,17 @@ export function useTimesheet({ const timesheetElementGroup = useMemo(() => { - if (timesheetGroupByDays === 'Daily') { - return groupByDate(timesheet); - } - if (timesheetGroupByDays === 'Weekly') { - return groupByWeek(timesheet); + if (timesheetViewMode === 'ListView') { + if (timesheetGroupByDays === 'Daily') { + return groupByDate(timesheet); + } + if (timesheetGroupByDays === 'Weekly') { + return groupByWeek(timesheet); + } + return groupByMonth(timesheet); } - return groupByMonth(timesheet); - }, [timesheetGroupByDays, timesheet]); + return groupByDate(timesheet); + }, [timesheetGroupByDays, timesheetViewMode, timesheet]); useEffect(() => { 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 2ee67b72e..117d90d43 100644 --- a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx +++ b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx @@ -56,7 +56,8 @@ import { StatusType, EmployeeAvatar, getTimesheetButtons, - statusTable + statusTable, + ProjectLogo } from '@/app/[locale]/timesheet/[memberId]/components'; import { useTranslations } from 'next-intl'; import { formatDate } from '@/app/helpers'; @@ -342,7 +343,10 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { taskNumberClassName="text-sm" />
- {task.project && task.project.name} +
+ {task.project?.imageUrl && } + {task.project?.name} +
{ const targetDateISO = new Date(createdAt).toISOString(); + const filteredLogs = timesheetLog.filter( (item) => formatDate(item.timesheet.createdAt) === formatDate(targetDateISO)); const totalDurationInSeconds = filteredLogs.reduce( diff --git a/apps/web/locales/ar.json b/apps/web/locales/ar.json index cec448f14..c94dd66a5 100644 --- a/apps/web/locales/ar.json +++ b/apps/web/locales/ar.json @@ -240,6 +240,15 @@ "SINGULAR": "ورقة الحضور", "PLURAL": "ورقات الحضور" }, + "DAYS": { + "sun": "الأحد", + "mon": "الإثنين", + "tue": "الثلاثاء", + "wed": "الأربعاء", + "thu": "الخميس", + "fri": "الجمعة", + "sat": "السبت" + }, "COPY_ISSUE_LINK": "نسخ رابط المشكلة", "MAKE_A_COPY": "إنشاء نسخة", "ASSIGNEE": "المسند إليه", diff --git a/apps/web/locales/bg.json b/apps/web/locales/bg.json index ce2a85668..19cc6cc07 100644 --- a/apps/web/locales/bg.json +++ b/apps/web/locales/bg.json @@ -258,6 +258,15 @@ "SINGULAR": "Работен лист", "PLURAL": "Работни листове" }, + "DAYS": { + "sun": "Нед", + "mon": "Пон", + "tue": "Вто", + "wed": "Сря", + "thu": "Чет", + "fri": "Пет", + "sat": "Съб" + }, "COPY_ISSUE_LINK": "Копирай връзката на проблема", "MAKE_A_COPY": "Направете копие", "ASSIGNEE": "Назначен", diff --git a/apps/web/locales/de.json b/apps/web/locales/de.json index 38a13ed56..fa9e07c55 100644 --- a/apps/web/locales/de.json +++ b/apps/web/locales/de.json @@ -258,6 +258,15 @@ "SINGULAR": "Stundenzettel", "PLURAL": "Stundenzettel" }, + "DAYS": { + "sun": "So", + "mon": "Mo", + "tue": "Di", + "wed": "Mi", + "thu": "Do", + "fri": "Fr", + "sat": "Sa" + }, "COPY_ISSUE_LINK": "Problemlink kopieren", "MAKE_A_COPY": "Eine Kopie erstellen", "ASSIGNEE": "Zessionar", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 180cfd86b..978ff6969 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -258,6 +258,15 @@ "SINGULAR": "Timesheet", "PLURAL": "Timesheets" }, + "DAYS": { + "sun": "Sun", + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat" + }, "COPY_ISSUE_LINK": "Copy issue link", "MAKE_A_COPY": "Make a copy", "ASSIGNEE": "Assignee", diff --git a/apps/web/locales/es.json b/apps/web/locales/es.json index 78196ca87..5a1b3a1c0 100644 --- a/apps/web/locales/es.json +++ b/apps/web/locales/es.json @@ -258,6 +258,15 @@ "SINGULAR": "Hoja de horas", "PLURAL": "Hojas de horas" }, + "DAYS": { + "sun": "Dom", + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb" + }, "COPY_ISSUE_LINK": "Copiar enlace del problema", "MAKE_A_COPY": "Hacer una copia", "ASSIGNEE": "Cesionario", diff --git a/apps/web/locales/fr.json b/apps/web/locales/fr.json index f603ed553..88e51f215 100644 --- a/apps/web/locales/fr.json +++ b/apps/web/locales/fr.json @@ -258,6 +258,15 @@ "SINGULAR": "Feuille de temps", "PLURAL": "Feuilles de temps" }, + "DAYS": { + "sun": "Dim", + "mon": "Lun", + "tue": "Mar", + "wed": "Mer", + "thu": "Jeu", + "fri": "Ven", + "sat": "Sam" + }, "COPY_ISSUE_LINK": "Copier le lien du problème", "MAKE_A_COPY": "Faire une copie", "ASSIGNEE": "Cessionnaire", diff --git a/apps/web/locales/he.json b/apps/web/locales/he.json index a1221c365..0177769f8 100644 --- a/apps/web/locales/he.json +++ b/apps/web/locales/he.json @@ -258,6 +258,15 @@ "SINGULAR": "דוח שעות", "PLURAL": "דוחות שעות" }, + "DAYS": { + "sun": "יום א׳", + "mon": "יום ב׳", + "tue": "יום ג׳", + "wed": "יום ד׳", + "thu": "יום ה׳", + "fri": "יום ו׳", + "sat": "שבת" + }, "COPY_ISSUE_LINK": "העתק קישור לבעיה", "MAKE_A_COPY": "בצע עותק", "ASSIGNEE": "נמען", diff --git a/apps/web/locales/it.json b/apps/web/locales/it.json index e7d7ab32c..b8e80cd98 100644 --- a/apps/web/locales/it.json +++ b/apps/web/locales/it.json @@ -241,6 +241,15 @@ "SINGULAR": "Scheda attività", "PLURAL": "Schede attività" }, + "DAYS": { + "sun": "Dom", + "mon": "Lun", + "tue": "Mar", + "wed": "Mer", + "thu": "Gio", + "fri": "Ven", + "sat": "Sab" + }, "CALENDAR": "Calendario", "SELECT": "Seleziona", "SAVE_CHANGES": "Salva modifiche", diff --git a/apps/web/locales/nl.json b/apps/web/locales/nl.json index 5bc5d834c..8768d926f 100644 --- a/apps/web/locales/nl.json +++ b/apps/web/locales/nl.json @@ -241,6 +241,15 @@ "SINGULAR": "Urenstaat", "PLURAL": "Urenstaten" }, + "DAYS": { + "sun": "Zon", + "mon": "Maa", + "tue": "Din", + "wed": "Woe", + "thu": "Don", + "fri": "Vri", + "sat": "Zat" + }, "CALENDAR": "Kalender", "SELECT": "Selecteren", "SAVE_CHANGES": "Wijzigingen opslaan", diff --git a/apps/web/locales/pl.json b/apps/web/locales/pl.json index ce7d15b24..4b98a4d8b 100644 --- a/apps/web/locales/pl.json +++ b/apps/web/locales/pl.json @@ -241,6 +241,15 @@ "SINGULAR": "Karta pracy", "PLURAL": "Karty pracy" }, + "DAYS": { + "sun": "Nie", + "mon": "Pon", + "tue": "Wto", + "wed": "Śro", + "thu": "Czw", + "fri": "Pią", + "sat": "Sob" + }, "CALENDAR": "Kalendarz", "SELECT": "Wybierz", "SAVE_CHANGES": "Zapisz zmiany", diff --git a/apps/web/locales/pt.json b/apps/web/locales/pt.json index bacf7d021..a587193be 100644 --- a/apps/web/locales/pt.json +++ b/apps/web/locales/pt.json @@ -242,6 +242,15 @@ "SINGULAR": "Folha de ponto", "PLURAL": "Folhas de ponto" }, + "DAYS": { + "sun": "Dom", + "mon": "Seg", + "tue": "Ter", + "wed": "Qua", + "thu": "Qui", + "fri": "Sex", + "sat": "Sáb" + }, "CALENDAR": "Calendário", "SELECT": "Selecionar", "SAVE_CHANGES": "Salvar alterações", diff --git a/apps/web/locales/ru.json b/apps/web/locales/ru.json index 1819cc5f6..0e7dab1af 100644 --- a/apps/web/locales/ru.json +++ b/apps/web/locales/ru.json @@ -241,6 +241,15 @@ "SINGULAR": "Табель", "PLURAL": "Табели" }, + "DAYS": { + "sun": "Вс", + "mon": "Пн", + "tue": "Вт", + "wed": "Ср", + "thu": "Чт", + "fri": "Пт", + "sat": "Сб" + }, "CALENDAR": "Календарь", "SELECT": "Выбрать", "SAVE_CHANGES": "Сохранить изменения", diff --git a/apps/web/locales/zh.json b/apps/web/locales/zh.json index 2b4f4b63a..f53aeb649 100644 --- a/apps/web/locales/zh.json +++ b/apps/web/locales/zh.json @@ -241,6 +241,15 @@ "SINGULAR": "时间表", "PLURAL": "时间表" }, + "DAYS": { + "sun": "日", + "mon": "一", + "tue": "二", + "wed": "三", + "thu": "四", + "fri": "五", + "sat": "六" + }, "CALENDAR": "日历", "SELECT": "Select", "SAVE_CHANGES": "保存更改",