feat(reporting): implement timezone support in reporting allocation and related components
- Added timezone handling in the getMemberTimeSheets method to ensure accurate date calculations based on user timezone. - Created ReportingControllerBaseWithTimezone to centralize timezone-related logic for reporting. - Introduced a migration to add a timezone column to the users table for better user experience. - Updated frontend API services and hooks to include user's timezone in requests. - Enhanced members time reports page to display time logs in the user's local timezone.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
import apiClient from '../api-client';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
|
||||
import {
|
||||
IProjectLogsBreakdown,
|
||||
IRPTTimeMember,
|
||||
IRPTTimeProject,
|
||||
ITimeLogBreakdownReq,
|
||||
} from '@/types/reporting/reporting.types';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/reporting`;
|
||||
|
||||
// Helper function to get user's timezone
|
||||
const getUserTimezone = () => {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
};
|
||||
|
||||
export const reportingTimesheetApiService = {
|
||||
getTimeSheetData: async (
|
||||
body = {},
|
||||
archived = false
|
||||
): Promise<IServerResponse<IAllocationViewModel>> => {
|
||||
const q = toQueryString({ archived });
|
||||
const bodyWithTimezone = {
|
||||
...body,
|
||||
timezone: getUserTimezone()
|
||||
};
|
||||
const response = await apiClient.post(`${rootUrl}/allocation/${q}`, bodyWithTimezone);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAllocationProjects: async (body = {}) => {
|
||||
const bodyWithTimezone = {
|
||||
...body,
|
||||
timezone: getUserTimezone()
|
||||
};
|
||||
const response = await apiClient.post(`${rootUrl}/allocation/allocation-projects`, bodyWithTimezone);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getProjectTimeSheets: async (
|
||||
body = {},
|
||||
archived = false
|
||||
): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
||||
const q = toQueryString({ archived });
|
||||
const bodyWithTimezone = {
|
||||
...body,
|
||||
timezone: getUserTimezone()
|
||||
};
|
||||
const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, bodyWithTimezone);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMemberTimeSheets: async (
|
||||
body = {},
|
||||
archived = false
|
||||
): Promise<IServerResponse<IRPTTimeMember[]>> => {
|
||||
const q = toQueryString({ archived });
|
||||
const bodyWithTimezone = {
|
||||
...body,
|
||||
timezone: getUserTimezone()
|
||||
};
|
||||
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, bodyWithTimezone);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getProjectTimeLogs: async (
|
||||
body: ITimeLogBreakdownReq
|
||||
): Promise<IServerResponse<IProjectLogsBreakdown[]>> => {
|
||||
const bodyWithTimezone = {
|
||||
...body,
|
||||
timezone: getUserTimezone()
|
||||
};
|
||||
const response = await apiClient.post(`${rootUrl}/project-timelogs`, bodyWithTimezone);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getProjectEstimatedVsActual: async (
|
||||
body = {},
|
||||
archived = false
|
||||
): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
||||
const q = toQueryString({ archived });
|
||||
const bodyWithTimezone = {
|
||||
...body,
|
||||
timezone: getUserTimezone()
|
||||
};
|
||||
const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, bodyWithTimezone);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
70
worklenz-frontend/src/hooks/useUserTimezone.ts
Normal file
70
worklenz-frontend/src/hooks/useUserTimezone.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook to get and manage user's timezone
|
||||
* @returns {Object} Object containing timezone and related utilities
|
||||
*/
|
||||
export const useUserTimezone = () => {
|
||||
const [timezone, setTimezone] = useState<string>('UTC');
|
||||
const [timezoneOffset, setTimezoneOffset] = useState<string>('+00:00');
|
||||
|
||||
useEffect(() => {
|
||||
// Get browser's timezone
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
setTimezone(browserTimezone);
|
||||
|
||||
// Calculate timezone offset
|
||||
const date = new Date();
|
||||
const offset = -date.getTimezoneOffset();
|
||||
const hours = Math.floor(Math.abs(offset) / 60);
|
||||
const minutes = Math.abs(offset) % 60;
|
||||
const sign = offset >= 0 ? '+' : '-';
|
||||
const formattedOffset = `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
setTimezoneOffset(formattedOffset);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Format a date in the user's timezone
|
||||
* @param date - Date to format
|
||||
* @param format - Format options
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
const formatInUserTimezone = (date: Date | string, format?: Intl.DateTimeFormatOptions) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return dateObj.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
...format
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the start of day in user's timezone
|
||||
* @param date - Date to get start of day for
|
||||
* @returns Date object representing start of day
|
||||
*/
|
||||
const getStartOfDayInTimezone = (date: Date = new Date()) => {
|
||||
const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
|
||||
localDate.setHours(0, 0, 0, 0);
|
||||
return localDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the end of day in user's timezone
|
||||
* @param date - Date to get end of day for
|
||||
* @returns Date object representing end of day
|
||||
*/
|
||||
const getEndOfDayInTimezone = (date: Date = new Date()) => {
|
||||
const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
|
||||
localDate.setHours(23, 59, 59, 999);
|
||||
return localDate;
|
||||
};
|
||||
|
||||
return {
|
||||
timezone,
|
||||
timezoneOffset,
|
||||
formatInUserTimezone,
|
||||
getStartOfDayInTimezone,
|
||||
getEndOfDayInTimezone,
|
||||
setTimezone
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Card, Flex, Typography, Tooltip } from '@/shared/antd-imports';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import MembersTimeSheet, {
|
||||
MembersTimeSheetRef,
|
||||
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useRef } from 'react';
|
||||
import { useUserTimezone } from '@/hooks/useUserTimezone';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MembersTimeReports = () => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const chartRef = useRef<MembersTimeSheetRef>(null);
|
||||
const { timezone, timezoneOffset } = useUserTimezone();
|
||||
|
||||
useDocumentTitle('Reporting - Allocation');
|
||||
|
||||
const handleExport = (type: string) => {
|
||||
if (type === 'png') {
|
||||
chartRef.current?.exportChart();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
title={t('Members Time Sheet')}
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
|
||||
<Card
|
||||
style={{ borderRadius: '4px' }}
|
||||
title={
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<TimeReportPageHeader />
|
||||
<Tooltip
|
||||
title={`All time logs are displayed in your local timezone. Times were logged by users in their respective timezones and converted for your viewing.`}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
||||
Timezone: {timezone} ({timezoneOffset})
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</div>
|
||||
}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 300px)',
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MembersTimeSheet ref={chartRef} />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersTimeReports;
|
||||
Reference in New Issue
Block a user