feat(localization): update and enhance localization files for multiple languages

- Updated localization files for various languages, including English, German, Spanish, Portuguese, and Chinese, to ensure consistency and accuracy across the application.
- Added new keys and updated existing ones to support recent UI changes and features, particularly in project views, task lists, and admin center settings.
- Enhanced the structure of localization files to improve maintainability and facilitate future updates.
- Implemented performance optimizations in the frontend components to better handle localization data.
This commit is contained in:
chamiakJ
2025-07-28 07:19:55 +05:30
parent fc88c14b94
commit 591d348ae5
315 changed files with 9956 additions and 6116 deletions

View File

@@ -53,7 +53,7 @@ const AccountSetup: React.FC = () => {
trackMixpanelEvent(evt_account_setup_visit);
const verifyAuthStatus = async () => {
try {
const response = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
const response = (await dispatch(verifyAuthentication()).unwrap()) as IAuthorizeResponse;
if (response?.authenticated) {
setSession(response.user);
dispatch(setUser(response.user));
@@ -163,10 +163,12 @@ const AccountSetup: React.FC = () => {
const res = await profileSettingsApiService.setupAccount(model);
if (res.done && res.body.id) {
trackMixpanelEvent(skip ? evt_account_setup_skip_invite : evt_account_setup_complete);
// Refresh user session to update setup_completed status
try {
const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
const authResponse = (await dispatch(
verifyAuthentication()
).unwrap()) as IAuthorizeResponse;
if (authResponse?.authenticated && authResponse?.user) {
setSession(authResponse.user);
dispatch(setUser(authResponse.user));
@@ -174,7 +176,7 @@ const AccountSetup: React.FC = () => {
} catch (error) {
logger.error('Failed to refresh user session after setup completion', error);
}
navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
}
} catch (error) {

View File

@@ -1,71 +1,40 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Card,
DatabaseOutlined,
message,
Space,
Typography,
} from '@/shared/antd-imports';
import React, { useEffect } from 'react';
import { Card, Space, Typography } from '@/shared/antd-imports';
import { PageHeader } from '@ant-design/pro-components';
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { RootState } from '@/app/store';
import { useTranslation } from 'react-i18next';
import OrganizationName from '@/components/admin-center/overview/organization-name/organization-name';
import OrganizationOwner from '@/components/admin-center/overview/organization-owner/organization-owner';
import HolidayCalendar from '@/components/admin-center/overview/holiday-calendar/holiday-calendar';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { holidayApiService } from '@/api/holiday/holiday.api.service';
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
import {
fetchOrganizationDetails,
fetchOrganizationAdmins,
} from '@/features/admin-center/admin-center.slice';
import logger from '@/utils/errorLogger';
const Overview: React.FC = () => {
const [organization, setOrganization] = useState<IOrganization | null>(null);
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
const [loadingAdmins, setLoadingAdmins] = useState(false);
const [populatingHolidays, setPopulatingHolidays] = useState(false);
const dispatch = useAppDispatch();
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const { organization, organizationAdmins, loadingOrganizationAdmins } = useAppSelector(
(state: RootState) => state.adminCenterReducer
);
const { t } = useTranslation('admin-center/overview');
const getOrganizationDetails = async () => {
try {
const res = await adminCenterApiService.getOrganizationDetails();
if (res.done) {
setOrganization(res.body);
}
await dispatch(fetchOrganizationDetails()).unwrap();
} catch (error) {
logger.error('Error getting organization details', error);
}
};
const getOrganizationAdmins = async () => {
setLoadingAdmins(true);
try {
const res = await adminCenterApiService.getOrganizationAdmins();
if (res.done) {
setOrganizationAdmins(res.body);
}
await dispatch(fetchOrganizationAdmins()).unwrap();
} catch (error) {
logger.error('Error getting organization admins', error);
} finally {
setLoadingAdmins(false);
}
};
const handlePopulateHolidays = async () => {
setPopulatingHolidays(true);
try {
const res = await holidayApiService.populateCountryHolidays();
if (res.done) {
message.success(`Successfully populated ${res.body.total_populated} holidays`);
}
} catch (error) {
logger.error('Error populating holidays', error);
message.error('Failed to populate holidays');
} finally {
setPopulatingHolidays(false);
}
};
@@ -76,20 +45,7 @@ const Overview: React.FC = () => {
return (
<div style={{ width: '100%' }}>
<PageHeader
title={<span>{t('overview')}</span>}
style={{ padding: '16px 0' }}
extra={[
<Button
key="populate-holidays"
icon={<DatabaseOutlined />}
onClick={handlePopulateHolidays}
loading={populatingHolidays}
>
Populate Holidays Database
</Button>
]}
/>
<PageHeader title={<span>{t('overview')}</span>} style={{ padding: '16px 0' }} />
<Space direction="vertical" style={{ width: '100%' }} size={22}>
<OrganizationName
@@ -112,12 +68,10 @@ const Overview: React.FC = () => {
</Typography.Title>
<OrganizationAdminsTable
organizationAdmins={organizationAdmins}
loading={loadingAdmins}
loading={loadingOrganizationAdmins}
themeMode={themeMode}
/>
</Card>
<HolidayCalendar themeMode={themeMode} />
</Space>
</div>
);

View File

@@ -1 +1 @@
export { default } from './settings';
export { default } from './settings';

View File

@@ -10,36 +10,68 @@ import {
Form,
Row,
message,
Select,
Switch,
Divider,
} from '@/shared/antd-imports';
import { PageHeader } from '@ant-design/pro-components';
import { useTranslation } from 'react-i18next';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IOrganization } from '@/types/admin-center/admin-center.types';
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { scheduleAPIService } from '@/api/schedule/schedule.api.service';
import { Settings } from '@/types/schedule/schedule-v2.types';
import OrganizationCalculationMethod from '@/components/admin-center/overview/organization-calculation-method/organization-calculation-method';
import HolidayCalendar from '@/components/admin-center/overview/holiday-calendar/holiday-calendar';
import { holidayApiService } from '@/api/holiday/holiday.api.service';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { RootState } from '@/app/store';
import {
fetchOrganizationDetails,
fetchOrganizationAdmins,
fetchHolidaySettings,
updateHolidaySettings,
fetchCountriesWithStates,
} from '@/features/admin-center/admin-center.slice';
const SettingsPage: React.FC = () => {
const [organization, setOrganization] = useState<IOrganization | null>(null);
const dispatch = useAppDispatch();
const {
organization,
organizationAdmins,
loadingOrganization,
loadingOrganizationAdmins,
holidaySettings,
loadingHolidaySettings,
countriesWithStates,
loadingCountries,
} = useAppSelector((state: RootState) => state.adminCenterReducer);
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const [workingDays, setWorkingDays] = useState<Settings['workingDays']>([]);
const [workingHours, setWorkingHours] = useState<Settings['workingHours']>(8);
const [saving, setSaving] = useState(false);
const [savingHolidays, setSavingHolidays] = useState(false);
const [form] = Form.useForm();
const [holidayForm] = Form.useForm();
const { t } = useTranslation('admin-center/settings');
const getOrganizationDetails = async () => {
try {
const res = await adminCenterApiService.getOrganizationDetails();
if (res.done) {
setOrganization(res.body);
}
await dispatch(fetchOrganizationDetails()).unwrap();
} catch (error) {
logger.error('Error getting organization details', error);
}
};
const getOrganizationAdmins = async () => {
try {
await dispatch(fetchOrganizationAdmins()).unwrap();
} catch (error) {
logger.error('Error getting organization admins', error);
}
};
const getOrgWorkingSettings = async () => {
try {
const res = await scheduleAPIService.fetchScheduleSettings();
@@ -87,9 +119,42 @@ const SettingsPage: React.FC = () => {
useEffect(() => {
getOrganizationDetails();
getOrganizationAdmins();
getOrgWorkingSettings();
dispatch(fetchHolidaySettings());
dispatch(fetchCountriesWithStates());
}, []);
useEffect(() => {
if (holidaySettings) {
holidayForm.setFieldsValue({
country_code: holidaySettings.country_code,
state_code: holidaySettings.state_code,
auto_sync_holidays: holidaySettings.auto_sync_holidays ?? true,
});
}
}, [holidaySettings, holidayForm]);
const handleHolidaySettingsSave = async (values: any) => {
setSavingHolidays(true);
try {
await dispatch(updateHolidaySettings(values)).unwrap();
message.success(t('holidaySettingsSaved') || 'Holiday settings saved successfully');
} catch (error) {
logger.error('Error updating holiday settings', error);
message.error(t('errorSavingHolidaySettings') || 'Error saving holiday settings');
} finally {
setSavingHolidays(false);
}
};
const getSelectedCountryStates = () => {
const selectedCountry = countriesWithStates.find(
country => country.code === holidayForm.getFieldValue('country_code')
);
return selectedCountry?.states || [];
};
return (
<div style={{ width: '100%' }}>
<PageHeader title={<span>{t('settings')}</span>} style={{ padding: '16px 0' }} />
@@ -148,9 +213,84 @@ const SettingsPage: React.FC = () => {
organization={organization}
refetch={getOrganizationDetails}
/>
<Card>
<Typography.Title level={5} style={{ margin: 0 }}>
{t('holidaySettings') || 'Holiday Settings'}
</Typography.Title>
<Form
layout="vertical"
form={holidayForm}
onFinish={handleHolidaySettingsSave}
style={{ marginTop: 16 }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label={t('country') || 'Country'}
name="country_code"
rules={[
{ required: true, message: t('countryRequired') || 'Please select a country' },
]}
>
<Select
placeholder={t('selectCountry') || 'Select country'}
loading={loadingCountries}
onChange={() => {
holidayForm.setFieldValue('state_code', undefined);
}}
showSearch
filterOption={(input, option) =>
(option?.children as string)?.toLowerCase().includes(input.toLowerCase())
}
>
{countriesWithStates.map(country => (
<Select.Option key={country.code} value={country.code}>
{country.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('state') || 'State/Province'} name="state_code">
<Select
placeholder={t('selectState') || 'Select state/province (optional)'}
allowClear
disabled={!holidayForm.getFieldValue('country_code')}
showSearch
filterOption={(input, option) =>
(option?.children as string)?.toLowerCase().includes(input.toLowerCase())
}
>
{getSelectedCountryStates().map(state => (
<Select.Option key={state.code} value={state.code}>
{state.name}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
label={t('autoSyncHolidays') || 'Automatically sync official holidays'}
name="auto_sync_holidays"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={savingHolidays}>
{t('saveHolidaySettings') || 'Save Holiday Settings'}
</Button>
</Form.Item>
</Form>
</Card>
<HolidayCalendar themeMode={themeMode} />
</Space>
</div>
);
};
export default SettingsPage;
export default SettingsPage;

View File

@@ -1,6 +1,15 @@
import { SearchOutlined, SyncOutlined } from '@/shared/antd-imports';
import { PageHeader } from '@ant-design/pro-components';
import { Button, Card, Flex, Input, Table, TableProps, Tooltip, Typography } from '@/shared/antd-imports';
import {
Button,
Card,
Flex,
Input,
Table,
TableProps,
Tooltip,
Typography,
} from '@/shared/antd-imports';
import React, { useEffect, useState } from 'react';
import { RootState } from '@/app/store';
import { useAppSelector } from '@/hooks/useAppSelector';

View File

@@ -118,7 +118,7 @@ const ForgotPasswordPage = () => {
>
<Input
prefix={<UserOutlined />}
placeholder={t('emailPlaceholder', {defaultValue: 'Enter your email'})}
placeholder={t('emailPlaceholder', { defaultValue: 'Enter your email' })}
size="large"
style={{ borderRadius: 4 }}
/>
@@ -134,7 +134,7 @@ const ForgotPasswordPage = () => {
loading={isLoading}
style={{ borderRadius: 4 }}
>
{t('resetPasswordButton', {defaultValue: 'Reset Password'})}
{t('resetPasswordButton', { defaultValue: 'Reset Password' })}
</Button>
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
<Link to="/auth/login">
@@ -146,7 +146,7 @@ const ForgotPasswordPage = () => {
borderRadius: 4,
}}
>
{t('returnToLoginButton', {defaultValue: 'Return to Login'})}
{t('returnToLoginButton', { defaultValue: 'Return to Login' })}
</Button>
</Link>
</Flex>

View File

@@ -18,25 +18,24 @@ const LoggingOutPage = () => {
try {
// Clear local session
await auth.signOut();
// Call backend logout
await authApiService.logout();
// Clear all caches using the utility
await CacheCleanup.clearAllCaches();
// Force a hard reload to ensure fresh state
setTimeout(() => {
CacheCleanup.forceReload('/auth/login');
}, 1000);
} catch (error) {
console.error('Logout error:', error);
// Fallback: force reload to login page
CacheCleanup.forceReload('/auth/login');
}
};
void logout();
}, [auth]);

View File

@@ -355,7 +355,9 @@ const SignupPage = () => {
}}
variant="outlined"
>
<PageHeader description={t('headerDescription', {defaultValue: 'Sign up to get started'})} />
<PageHeader
description={t('headerDescription', { defaultValue: 'Sign up to get started' })}
/>
<Form
form={form}
name="signup"
@@ -369,19 +371,27 @@ const SignupPage = () => {
name: urlParams.name,
}}
>
<Form.Item name="name" label={t('nameLabel', {defaultValue: 'Full Name'})} rules={formRules.name}>
<Form.Item
name="name"
label={t('nameLabel', { defaultValue: 'Full Name' })}
rules={formRules.name}
>
<Input
prefix={<UserOutlined />}
placeholder={t('namePlaceholder', {defaultValue: 'Enter your full name'})}
placeholder={t('namePlaceholder', { defaultValue: 'Enter your full name' })}
size="large"
style={{ borderRadius: 4 }}
/>
</Form.Item>
<Form.Item name="email" label={t('emailLabel', {defaultValue: 'Email'})} rules={formRules.email as Rule[]}>
<Form.Item
name="email"
label={t('emailLabel', { defaultValue: 'Email' })}
rules={formRules.email as Rule[]}
>
<Input
prefix={<MailOutlined />}
placeholder={t('emailPlaceholder', {defaultValue: 'Enter your email'})}
placeholder={t('emailPlaceholder', { defaultValue: 'Enter your email' })}
size="large"
style={{ borderRadius: 4 }}
/>
@@ -389,14 +399,16 @@ const SignupPage = () => {
<Form.Item
name="password"
label={t('passwordLabel', {defaultValue: 'Password'})}
label={t('passwordLabel', { defaultValue: 'Password' })}
rules={formRules.password}
validateTrigger={['onBlur', 'onSubmit']}
>
<div>
<Input.Password
prefix={<LockOutlined />}
placeholder={t('strongPasswordPlaceholder', {defaultValue: 'Enter a strong password'})}
placeholder={t('strongPasswordPlaceholder', {
defaultValue: 'Enter a strong password',
})}
size="large"
style={{ borderRadius: 4 }}
value={passwordValue}
@@ -409,9 +421,13 @@ const SignupPage = () => {
if (!passwordValue) setPasswordActive(false);
}}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, marginTop: 4, marginBottom: 0, display: 'block' }}>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginTop: 4, marginBottom: 0, display: 'block' }}
>
{t('passwordGuideline', {
defaultValue: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.'
defaultValue:
'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.',
})}
</Typography.Text>
{passwordActive && (
@@ -420,14 +436,22 @@ const SignupPage = () => {
const passed = item.test(passwordValue);
// Only green if passed, otherwise neutral (never red)
let color = passed
? (themeMode === 'dark' ? '#52c41a' : '#389e0d')
: (themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf');
? themeMode === 'dark'
? '#52c41a'
: '#389e0d'
: themeMode === 'dark'
? '#b0b3b8'
: '#bfbfbf';
return (
<Flex key={item.key} align="center" gap={8} style={{ color, fontSize: 13 }}>
{passed ? (
<CheckCircleTwoTone twoToneColor={themeMode === 'dark' ? '#52c41a' : '#52c41a'} />
<CheckCircleTwoTone
twoToneColor={themeMode === 'dark' ? '#52c41a' : '#52c41a'}
/>
) : (
<CloseCircleTwoTone twoToneColor={themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'} />
<CloseCircleTwoTone
twoToneColor={themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'}
/>
)}
<span>{item.label}</span>
</Flex>
@@ -491,7 +515,7 @@ const SignupPage = () => {
<Form.Item>
<Space>
<Typography.Text style={{ fontSize: 14 }}>
{t('alreadyHaveAccountText', {defaultValue: 'Already have an account?'})}
{t('alreadyHaveAccountText', { defaultValue: 'Already have an account?' })}
</Typography.Text>
<Link

View File

@@ -153,14 +153,22 @@ const VerifyResetEmailPage = () => {
{passwordChecklistItems.map(item => {
const passed = item.test(passwordValue);
let color = passed
? (themeMode === 'dark' ? '#52c41a' : '#389e0d')
: (themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf');
? themeMode === 'dark'
? '#52c41a'
: '#389e0d'
: themeMode === 'dark'
? '#b0b3b8'
: '#bfbfbf';
return (
<Flex key={item.key} align="center" gap={8} style={{ color, fontSize: 13 }}>
{passed ? (
<CheckCircleTwoTone twoToneColor={themeMode === 'dark' ? '#52c41a' : '#52c41a'} />
<CheckCircleTwoTone
twoToneColor={themeMode === 'dark' ? '#52c41a' : '#52c41a'}
/>
) : (
<CloseCircleTwoTone twoToneColor={themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'} />
<CloseCircleTwoTone
twoToneColor={themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'}
/>
)}
<span>{item.label}</span>
</Flex>

View File

@@ -44,7 +44,7 @@ const HomePage = memo(() => {
console.warn('Failed to preload TaskDrawer:', error);
}
};
preloadTaskDrawer();
}, []);
@@ -131,8 +131,8 @@ const HomePage = memo(() => {
{createPortal(
<React.Suspense fallback={null}>
<TaskDrawer />
</React.Suspense>,
document.body,
</React.Suspense>,
document.body,
'home-task-drawer'
)}
</Suspense>

View File

@@ -1,4 +1,13 @@
import { Alert, DatePicker, Flex, Form, Input, InputRef, Select, Typography } from '@/shared/antd-imports';
import {
Alert,
DatePicker,
Flex,
Form,
Input,
InputRef,
Select,
Typography,
} from '@/shared/antd-imports';
import { useEffect, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { TFunction } from 'i18next';

View File

@@ -13,33 +13,33 @@
flex-direction: column;
gap: 12px;
}
.task-list-card .ant-card-head-title {
flex: 1;
width: 100%;
}
.task-list-card .ant-card-extra {
width: 100%;
justify-content: space-between;
}
.task-list-mobile-header {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
.task-list-mobile-controls {
flex-direction: column;
gap: 8px;
align-items: stretch !important;
}
.task-list-mobile-select {
width: 100% !important;
}
.task-list-mobile-segmented {
width: 100% !important;
}
@@ -49,20 +49,20 @@
.task-list-card .ant-table {
font-size: 12px;
}
.task-list-card .ant-table-thead > tr > th {
padding: 8px 4px;
font-size: 12px;
}
.task-list-card .ant-table-tbody > tr > td {
padding: 8px 4px;
}
.row-action-button {
opacity: 1; /* Always show on mobile */
}
/* Hide project column on very small screens */
.task-list-card .ant-table-thead > tr > th:nth-child(2),
.task-list-card .ant-table-tbody > tr > td:nth-child(2) {
@@ -79,7 +79,7 @@
.task-list-card .ant-table-wrapper {
overflow-x: auto;
}
.task-list-card .ant-table {
min-width: 600px;
}

View File

@@ -129,9 +129,7 @@ const TasksList: React.FC = React.memo(() => {
render: (_, record) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Tooltip title={record.name}>
<Typography.Text style={{ flex: 1, marginRight: 8 }}>
{record.name}
</Typography.Text>
<Typography.Text style={{ flex: 1, marginRight: 8 }}>{record.name}</Typography.Text>
</Tooltip>
<div className="row-action-button">
<Tooltip title={'Click open task form'}>
@@ -161,9 +159,7 @@ const TasksList: React.FC = React.memo(() => {
render: (_, record) => {
return (
<Tooltip title={record.project_name}>
<Typography.Paragraph
style={{ margin: 0, paddingInlineEnd: 6 }}
>
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6 }}>
<Badge color={record.project_color || 'blue'} style={{ marginInlineEnd: 4 }} />
{record.project_name}
</Typography.Paragraph>
@@ -212,7 +208,6 @@ const TasksList: React.FC = React.memo(() => {
);
}, [dispatch]);
return (
<Card
className="task-list-card"

View File

@@ -117,16 +117,16 @@ const ProjectList: React.FC = () => {
statuses: requestParams.statuses,
categories: requestParams.categories,
};
// Create a stable key for comparison
const paramsKey = JSON.stringify(params);
// Only return new params if they've actually changed
if (paramsKey !== lastQueryParamsRef.current) {
lastQueryParamsRef.current = paramsKey;
return params;
}
// Return the previous params to maintain reference stability
return JSON.parse(lastQueryParamsRef.current || '{}');
}, [requestParams]);
@@ -147,8 +147,6 @@ const ProjectList: React.FC = () => {
skip: viewMode === ProjectViewType.GROUP,
});
// Add performance monitoring
const performanceRef = useRef<{ startTime: number | null }>({ startTime: null });
@@ -163,15 +161,17 @@ const ProjectList: React.FC = () => {
// Optimized debounced search with better cleanup and performance
const debouncedSearch = useCallback(
debounce((searchTerm: string) => {
debounce((searchTerm: string) => {
// Clear any error messages when starting a new search
setErrorMessage(null);
if (viewMode === ProjectViewType.LIST) {
dispatch(setRequestParams({
search: searchTerm,
index: 1 // Reset to first page on search
}));
dispatch(
setRequestParams({
search: searchTerm,
index: 1, // Reset to first page on search
})
);
} else if (viewMode === ProjectViewType.GROUP) {
const newGroupedParams = {
...groupedRequestParams,
@@ -179,12 +179,12 @@ const ProjectList: React.FC = () => {
index: 1,
};
dispatch(setGroupedRequestParams(newGroupedParams));
// Add timeout for grouped search to prevent rapid API calls
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
dispatch(fetchGroupedProjects(newGroupedParams));
}, 100);
@@ -208,21 +208,21 @@ const ProjectList: React.FC = () => {
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newSearchValue = e.target.value;
// Validate input length to prevent excessive API calls
if (newSearchValue.length > 100) {
return; // Prevent extremely long search terms
}
setSearchValue(newSearchValue);
trackMixpanelEvent(evt_projects_search);
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
// Debounce the actual search execution
debouncedSearch(newSearchValue);
},
@@ -381,7 +381,7 @@ const ProjectList: React.FC = () => {
trackMixpanelEvent(evt_projects_refresh_click);
setIsLoading(true);
setErrorMessage(null);
if (viewMode === ProjectViewType.LIST) {
await refetchProjects();
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
@@ -398,7 +398,7 @@ const ProjectList: React.FC = () => {
const emptyContent = useMemo(() => {
if (errorMessage) {
return (
<Empty
<Empty
description={
<div>
<p>{errorMessage}</p>
@@ -406,7 +406,7 @@ const ProjectList: React.FC = () => {
Retry
</Button>
</div>
}
}
/>
);
}
@@ -455,7 +455,11 @@ const ProjectList: React.FC = () => {
const newOrder = Array.isArray(sorter) ? sorter[0].order : sorter.order;
const newField = (Array.isArray(sorter) ? sorter[0].columnKey : sorter.columnKey) as string;
if (newOrder && newField && (newOrder !== requestParams.order || newField !== requestParams.field)) {
if (
newOrder &&
newField &&
(newOrder !== requestParams.order || newField !== requestParams.field)
) {
updates.order = newOrder ?? 'ascend';
updates.field = newField ?? 'name';
setSortingValues(updates.field, updates.order);
@@ -463,7 +467,10 @@ const ProjectList: React.FC = () => {
}
// Handle pagination
if (newPagination.current !== requestParams.index || newPagination.pageSize !== requestParams.size) {
if (
newPagination.current !== requestParams.index ||
newPagination.pageSize !== requestParams.size
) {
updates.index = newPagination.current || 1;
updates.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
hasChanges = true;
@@ -494,9 +501,12 @@ const ProjectList: React.FC = () => {
index: newPagination.current || 1,
size: newPagination.pageSize || DEFAULT_PAGE_SIZE,
};
// Only update if values actually changed
if (newParams.index !== groupedRequestParams.index || newParams.size !== groupedRequestParams.size) {
if (
newParams.index !== groupedRequestParams.index ||
newParams.size !== groupedRequestParams.size
) {
dispatch(setGroupedRequestParams(newParams));
}
},
@@ -511,19 +521,23 @@ const ProjectList: React.FC = () => {
// Batch updates to reduce re-renders
const baseUpdates = { filter: newFilterIndex, index: 1 };
dispatch(setRequestParams(baseUpdates));
dispatch(setGroupedRequestParams({
...groupedRequestParams,
...baseUpdates,
}));
dispatch(
setGroupedRequestParams({
...groupedRequestParams,
...baseUpdates,
})
);
// Only trigger data fetch for group view (list view will auto-refetch via query)
if (viewMode === ProjectViewType.GROUP && groupBy) {
dispatch(fetchGroupedProjects({
...groupedRequestParams,
...baseUpdates,
}));
dispatch(
fetchGroupedProjects({
...groupedRequestParams,
...baseUpdates,
})
);
}
},
[filters, setFilterIndex, dispatch, groupedRequestParams, viewMode, groupBy]
@@ -697,26 +711,28 @@ const ProjectList: React.FC = () => {
useEffect(() => {
const filterIndex = getFilterIndex();
const initialParams = { filter: filterIndex };
// Only update if values are different
if (requestParams.filter !== filterIndex) {
dispatch(setRequestParams(initialParams));
}
// Initialize grouped request params with proper groupBy value
if (!groupedRequestParams.groupBy) {
const initialGroupBy = groupBy || ProjectGroupBy.CATEGORY;
dispatch(setGroupedRequestParams({
filter: filterIndex,
index: 1,
size: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
search: '',
groupBy: initialGroupBy,
statuses: null,
categories: null,
}));
dispatch(
setGroupedRequestParams({
filter: filterIndex,
index: 1,
size: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
search: '',
groupBy: initialGroupBy,
statuses: null,
categories: null,
})
);
}
}, [dispatch, getFilterIndex, groupBy]); // Add groupBy to deps to handle initial state
@@ -732,8 +748,9 @@ const ProjectList: React.FC = () => {
// 2. We have a groupBy value (either from Redux or default)
if (viewMode === ProjectViewType.GROUP && groupBy) {
// Always ensure grouped request params are properly set with current groupBy
const shouldUpdateParams = !groupedRequestParams.groupBy || groupedRequestParams.groupBy !== groupBy;
const shouldUpdateParams =
!groupedRequestParams.groupBy || groupedRequestParams.groupBy !== groupBy;
if (shouldUpdateParams) {
const updatedParams = {
...groupedRequestParams,
@@ -757,7 +774,7 @@ const ProjectList: React.FC = () => {
useEffect(() => {
const loadLookups = async () => {
const promises = [];
if (projectStatuses.length === 0) {
promises.push(dispatch(fetchProjectStatuses()));
}
@@ -767,19 +784,20 @@ const ProjectList: React.FC = () => {
if (projectHealths.length === 0) {
promises.push(dispatch(fetchProjectHealth()));
}
// Load all lookups in parallel
if (promises.length > 0) {
await Promise.allSettled(promises);
}
};
loadLookups();
}, [dispatch]); // Remove length dependencies to avoid re-runs
// Sync search input value with Redux state
useEffect(() => {
const currentSearch = viewMode === ProjectViewType.LIST ? requestParams.search : groupedRequestParams.search;
const currentSearch =
viewMode === ProjectViewType.LIST ? requestParams.search : groupedRequestParams.search;
if (searchValue !== (currentSearch || '')) {
setSearchValue(currentSearch || '');
}
@@ -788,13 +806,13 @@ const ProjectList: React.FC = () => {
// Optimize loading state management
useEffect(() => {
let newLoadingState = false;
if (viewMode === ProjectViewType.LIST) {
newLoadingState = loadingProjects || isFetchingProjects;
} else {
newLoadingState = groupedProjects.loading;
}
// Only update if loading state actually changed
if (isLoading !== newLoadingState) {
setIsLoading(newLoadingState);

View File

@@ -1,5 +1,13 @@
import React, { useEffect, useState } from 'react';
import { Avatar, Checkbox, DatePicker, Flex, Tag, Tooltip, Typography } from '@/shared/antd-imports';
import {
Avatar,
Checkbox,
DatePicker,
Flex,
Tag,
Tooltip,
Typography,
} from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector';
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';

View File

@@ -1,7 +1,21 @@
import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from '@/shared/antd-imports';
import {
Badge,
Button,
Collapse,
ConfigProvider,
Dropdown,
Flex,
Input,
Typography,
} from '@/shared/antd-imports';
import { useState } from 'react';
import { TaskType } from '@/types/task.types';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@/shared/antd-imports';
import {
EditOutlined,
EllipsisOutlined,
RetweetOutlined,
RightOutlined,
} from '@/shared/antd-imports';
import { colors } from '@/styles/colors';
import './task-list-table-wrapper.css';
import TaskListTable from '../table-v2';

View File

@@ -1,4 +1,8 @@
import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@/shared/antd-imports';
import {
CaretDownFilled,
SortAscendingOutlined,
SortDescendingOutlined,
} from '@/shared/antd-imports';
import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from '@/shared/antd-imports';
import React, { useState } from 'react';
import { colors } from '../../../../../styles/colors';

View File

@@ -1,6 +1,20 @@
import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from '@/shared/antd-imports';
import {
Badge,
Button,
Collapse,
ConfigProvider,
Dropdown,
Flex,
Input,
Typography,
} from '@/shared/antd-imports';
import { useState } from 'react';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@/shared/antd-imports';
import {
EditOutlined,
EllipsisOutlined,
RetweetOutlined,
RightOutlined,
} from '@/shared/antd-imports';
import { colors } from '../../../../../styles/colors';
import './taskListTableWrapper.css';
import TaskListTable from './TaskListTable';

View File

@@ -1,6 +1,14 @@
import { useCallback, useState } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { Col, Flex, Typography, List, Dropdown, MenuProps, Popconfirm } from '@/shared/antd-imports';
import {
Col,
Flex,
Typography,
List,
Dropdown,
MenuProps,
Popconfirm,
} from '@/shared/antd-imports';
import {
UserAddOutlined,
DeleteOutlined,

View File

@@ -184,19 +184,19 @@ const ProjectViewHeader = memo(() => {
const handleSettingsClick = useCallback(() => {
if (selectedProject?.id) {
console.log('Opening project drawer from project view for project:', selectedProject.id);
// Set project ID first
dispatch(setProjectId(selectedProject.id));
// Then fetch project data
dispatch(fetchProjectData(selectedProject.id))
.unwrap()
.then((projectData) => {
.then(projectData => {
console.log('Project data fetched successfully from project view:', projectData);
// Open drawer after data is fetched
dispatch(toggleProjectDrawer());
})
.catch((error) => {
.catch(error => {
console.error('Failed to fetch project data from project view:', error);
// Still open drawer even if fetch fails, so user can see error state
dispatch(toggleProjectDrawer());
@@ -270,7 +270,11 @@ const ProjectViewHeader = memo(() => {
{
key: 'import',
label: (
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate} title={t('importTaskTooltip')}>
<div
style={{ width: '100%', margin: 0, padding: 0 }}
onClick={handleImportTaskTemplate}
title={t('importTaskTooltip')}
>
<ImportOutlined /> {t('importTask')}
</div>
),
@@ -287,7 +291,10 @@ const ProjectViewHeader = memo(() => {
if (selectedProject.category_id) {
elements.push(
<Tooltip key="category-tooltip" title={`${t('projectCategoryTooltip')}: ${selectedProject.category_name}`}>
<Tooltip
key="category-tooltip"
title={`${t('projectCategoryTooltip')}: ${selectedProject.category_name}`}
>
<Tag
key="category"
color={colors.vibrantOrange}
@@ -381,7 +388,10 @@ const ProjectViewHeader = memo(() => {
// Subscribe button
actions.push(
<Tooltip key="subscribe" title={selectedProject?.subscribed ? t('unsubscribeTooltip') : t('subscribeTooltip')}>
<Tooltip
key="subscribe"
title={selectedProject?.subscribed ? t('unsubscribeTooltip') : t('subscribeTooltip')}
>
<Button
shape="round"
loading={subscriptionLoading}
@@ -464,7 +474,10 @@ const ProjectViewHeader = memo(() => {
() => (
<Flex gap={4} align="center">
<Tooltip title={t('navigateBackTooltip')}>
<ArrowLeftOutlined style={{ fontSize: 16, cursor: 'pointer' }} onClick={handleNavigateToProjects} />
<ArrowLeftOutlined
style={{ fontSize: 16, cursor: 'pointer' }}
onClick={handleNavigateToProjects}
/>
</Tooltip>
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 8 }}>
{selectedProject?.name}

View File

@@ -26,5 +26,3 @@
[data-theme="dark"] .project-view-tabs .ant-tabs-ink-bar {
background-color: #ffffff;
}

View File

@@ -8,7 +8,12 @@ import Dropdown from 'antd/es/dropdown';
import Input from 'antd/es/input';
import Typography from 'antd/es/typography';
import { MenuProps } from 'antd/es/menu';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@/shared/antd-imports';
import {
EditOutlined,
EllipsisOutlined,
RetweetOutlined,
RightOutlined,
} from '@/shared/antd-imports';
import { colors } from '@/styles/colors';
import { useAppSelector } from '@/hooks/useAppSelector';

View File

@@ -17,7 +17,12 @@ import {
DragEndEvent,
DragStartEvent,
} from '@dnd-kit/core';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@/shared/antd-imports';
import {
EditOutlined,
EllipsisOutlined,
RetweetOutlined,
RightOutlined,
} from '@/shared/antd-imports';
import { colors } from '@/styles/colors';
import { useAppSelector } from '@/hooks/useAppSelector';

View File

@@ -1,4 +1,13 @@
import { Badge, Card, Dropdown, Empty, Flex, Menu, MenuProps, Typography } from '@/shared/antd-imports';
import {
Badge,
Card,
Dropdown,
Empty,
Flex,
Menu,
MenuProps,
Typography,
} from '@/shared/antd-imports';
import React, { useState, useEffect } from 'react';
import { DownOutlined } from '@/shared/antd-imports';
// custom css file

View File

@@ -1,4 +1,13 @@
import { Badge, Card, Dropdown, Empty, Flex, Menu, MenuProps, Typography } from '@/shared/antd-imports';
import {
Badge,
Card,
Dropdown,
Empty,
Flex,
Menu,
MenuProps,
Typography,
} from '@/shared/antd-imports';
import React, { useState, useEffect } from 'react';
import { DownOutlined } from '@/shared/antd-imports';
// custom css file

View File

@@ -43,8 +43,8 @@ import {
import { themeWiseColor } from '@/utils/themeWiseColor';
import KeyTypeColumn from './key-type-column/key-type-column';
import logger from '@/utils/errorLogger';
import {
fetchTasksV3,
import {
fetchTasksV3,
fetchTaskListColumns,
addCustomColumn,
deleteCustomColumn as deleteCustomColumnFromTaskManagement,
@@ -91,8 +91,6 @@ const CustomColumnModal = () => {
// Use the column data passed from TaskListV2
const openedColumn = currentColumnData;
// Function to reset all form and Redux state
const resetModalData = () => {
mainForm.resetFields();
@@ -104,11 +102,12 @@ const CustomColumnModal = () => {
const handleDeleteColumn = async () => {
// The customColumnId should now be the UUID passed from TaskListV2
// But also check the column data as a fallback, prioritizing uuid over id
const columnUUID = customColumnId ||
openedColumn?.uuid ||
openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
const columnUUID =
customColumnId ||
openedColumn?.uuid ||
openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
if (!customColumnId || !columnUUID) {
message.error('Cannot delete column: Missing UUID');
@@ -260,10 +259,10 @@ const CustomColumnModal = () => {
dispatch(addCustomColumn(newColumn));
dispatch(toggleCustomColumnModalOpen(false));
resetModalData();
// Show success message
message.success(t('customColumns.modal.createSuccessMessage'));
// Refresh tasks and columns to include the new custom column values
if (projectId) {
dispatch(fetchTaskListColumns(projectId));
@@ -301,11 +300,12 @@ const CustomColumnModal = () => {
: null;
// Get the correct UUID for the update operation, prioritizing uuid over id
const updateColumnUUID = customColumnId ||
openedColumn?.uuid ||
openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
const updateColumnUUID =
customColumnId ||
openedColumn?.uuid ||
openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
if (updatedColumn && updateColumnUUID) {
try {
@@ -377,7 +377,11 @@ const CustomColumnModal = () => {
return (
<Modal
title={customColumnModalType === 'create' ? t('customColumns.modal.addFieldTitle') : t('customColumns.modal.editFieldTitle')}
title={
customColumnModalType === 'create'
? t('customColumns.modal.addFieldTitle')
: t('customColumns.modal.editFieldTitle')
}
centered
open={isCustomColumnModalOpen}
onCancel={() => {
@@ -490,7 +494,10 @@ const CustomColumnModal = () => {
]}
required={false}
>
<Input placeholder={t('customColumns.modal.columnTitlePlaceholder')} style={{ minWidth: '100%', width: 300 }} />
<Input
placeholder={t('customColumns.modal.columnTitlePlaceholder')}
style={{ minWidth: '100%', width: 300 }}
/>
</Form.Item>
<Form.Item
@@ -541,10 +548,14 @@ const CustomColumnModal = () => {
)}
<Flex gap={8}>
<Button onClick={() => {
dispatch(toggleCustomColumnModalOpen(false));
resetModalData();
}}>{t('customColumns.modal.cancelButton')}</Button>
<Button
onClick={() => {
dispatch(toggleCustomColumnModalOpen(false));
resetModalData();
}}
>
{t('customColumns.modal.cancelButton')}
</Button>
{customColumnModalType === 'create' ? (
<Button type="primary" htmlType="submit">
{t('customColumns.modal.createButton')}

View File

@@ -35,8 +35,6 @@ const SelectionTypeColumn = () => {
// Use the current column data passed from TaskListV2
const openedColumn = currentColumnData;
// Load existing selections when in edit mode
useEffect(() => {
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {

View File

@@ -539,7 +539,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
updatedTasks.forEach((task, index) => {
taskUpdates.push({
task_id: task.id,
@@ -550,7 +550,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
// Different groups - update both source and target
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
if (isTargetGroupEmpty) {
updatedTargetTasks.push(task);
} else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) {
@@ -573,7 +573,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
task_id: task.id,
sort_order: index + 1,
};
// Add group-specific updates
if (groupBy === IGroupBy.STATUS) {
update.status_id = targetGroup.id;
@@ -582,7 +582,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
} else if (groupBy === IGroupBy.PHASE) {
update.phase_id = targetGroup.id;
}
taskUpdates.push(update);
});
}

View File

@@ -9,7 +9,12 @@ import Dropdown from 'antd/es/dropdown';
import Input from 'antd/es/input';
import Typography from 'antd/es/typography';
import { MenuProps } from 'antd/es/menu';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@/shared/antd-imports';
import {
EditOutlined,
EllipsisOutlined,
RetweetOutlined,
RightOutlined,
} from '@/shared/antd-imports';
import { colors } from '@/styles/colors';
import './task-list-table-wrapper.css';
import TaskListTable from '../task-list-table';

View File

@@ -944,8 +944,6 @@ const SelectionFieldCell: React.FC<{
columnKey: string;
updateValue: (taskId: string, columnKey: string, value: string) => void;
}> = ({ selectionsList, value, task, columnKey, updateValue }) => {
return (
<CustomColumnSelectionCell
selectionsList={selectionsList}

View File

@@ -1,4 +1,13 @@
import { Button, ConfigProvider, Flex, Form, Mentions, Space, Tooltip, Typography } from '@/shared/antd-imports';
import {
Button,
ConfigProvider,
Flex,
Form,
Mentions,
Space,
Tooltip,
Typography,
} from '@/shared/antd-imports';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../../hooks/useAppSelector';

View File

@@ -1,4 +1,13 @@
import { Button, Card, Checkbox, Dropdown, Flex, Skeleton, Space, Typography } from '@/shared/antd-imports';
import {
Button,
Card,
Checkbox,
Dropdown,
Flex,
Skeleton,
Space,
Typography,
} from '@/shared/antd-imports';
import { DownOutlined } from '@/shared/antd-imports';
import MembersReportsTable from './members-reports-table/members-reports-table';
import TimeWiseFilter from '@/components/reporting/time-wise-filter';

View File

@@ -5,7 +5,18 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectCategoryViewModel } from '@/types/project/projectCategory.types';
import { CaretDownFilled } from '@/shared/antd-imports';
import { Badge, Button, Card, Checkbox, Dropdown, Empty, Flex, Input, InputRef, List } from '@/shared/antd-imports';
import {
Badge,
Button,
Card,
Checkbox,
Dropdown,
Empty,
Flex,
Input,
InputRef,
List,
} from '@/shared/antd-imports';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -4,7 +4,17 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectManager } from '@/types/project/projectManager.types';
import { CaretDownFilled } from '@/shared/antd-imports';
import { Button, Card, Checkbox, Dropdown, Empty, Flex, Input, InputRef, List } from '@/shared/antd-imports';
import {
Button,
Card,
Checkbox,
Dropdown,
Empty,
Flex,
Input,
InputRef,
List,
} from '@/shared/antd-imports';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,5 +1,12 @@
import { useEffect, useState, useMemo, useCallback, memo } from 'react';
import { Button, ConfigProvider, Flex, PaginationProps, Table, TableColumnsType } from '@/shared/antd-imports';
import {
Button,
ConfigProvider,
Flex,
PaginationProps,
Table,
TableColumnsType,
} from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { ExpandAltOutlined } from '@/shared/antd-imports';

View File

@@ -1,6 +1,16 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { DownOutlined } from '@/shared/antd-imports';
import { Badge, Card, Dropdown, Flex, Input, InputRef, Menu, MenuProps, Typography } from '@/shared/antd-imports';
import {
Badge,
Card,
Dropdown,
Flex,
Input,
InputRef,
Menu,
MenuProps,
Typography,
} from '@/shared/antd-imports';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';

View File

@@ -20,365 +20,408 @@ import { format } from 'date-fns';
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
interface MembersTimeSheetProps {
onTotalsUpdate: (totals: { total_time_logs: string; total_estimated_hours: string; total_utilization: string }) => void;
onTotalsUpdate: (totals: {
total_time_logs: string;
total_estimated_hours: string;
total_utilization: string;
}) => void;
}
export interface MembersTimeSheetRef {
exportChart: () => void;
}
const MembersTimeSheet = forwardRef<MembersTimeSheetRef, MembersTimeSheetProps>(({ onTotalsUpdate }, ref) => {
const { t } = useTranslation('time-report');
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
const MembersTimeSheet = forwardRef<MembersTimeSheetRef, MembersTimeSheetProps>(
({ onTotalsUpdate }, ref) => {
const { t } = useTranslation('time-report');
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
const {
teams,
loadingTeams,
categories,
loadingCategories,
projects: filterProjects,
loadingProjects,
members,
loadingMembers,
utilization,
loadingUtilization,
billable,
archived,
noCategory,
} = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const {
teams,
loadingTeams,
categories,
loadingCategories,
projects: filterProjects,
loadingProjects,
members,
loadingMembers,
utilization,
loadingUtilization,
billable,
archived,
noCategory,
} = useAppSelector(state => state.timeReportsOverviewReducer);
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
const [loading, setLoading] = useState(false);
const [jsonData, setJsonData] = useState<IRPTTimeMember[]>([]);
const [loading, setLoading] = useState(false);
const [jsonData, setJsonData] = useState<IRPTTimeMember[]>([]);
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
const dataValues = Array.isArray(jsonData) ? jsonData.map(item => {
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
return loggedTimeInHours.toFixed(2);
}) : [];
const colors = Array.isArray(jsonData) ? jsonData.map(item => {
const utilizationPercent = parseFloat(item.utilization_percent || '0');
if (utilizationPercent < 90) {
return '#faad14'; // Orange for under-utilized (< 90%)
} else if (utilizationPercent <= 110) {
return '#52c41a'; // Green for optimal utilization (90-110%)
} else {
return '#ef4444'; // Red for over-utilized (> 110%)
}
}) : [];
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
const dataValues = Array.isArray(jsonData)
? jsonData.map(item => {
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
return loggedTimeInHours.toFixed(2);
})
: [];
const colors = Array.isArray(jsonData)
? jsonData.map(item => {
const utilizationPercent = parseFloat(item.utilization_percent || '0');
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Helper function to format hours to "X hours Y mins"
const formatHours = (decimalHours: number) => {
const wholeHours = Math.floor(decimalHours);
const minutes = Math.round((decimalHours - wholeHours) * 60);
if (wholeHours === 0 && minutes === 0) {
return '0 mins';
} else if (wholeHours === 0) {
return `${minutes} mins`;
} else if (minutes === 0) {
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
} else {
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
}
};
// Chart data
const data = {
labels,
datasets: [
{
label: t('loggedTime'),
data: dataValues,
backgroundColor: colors,
barThickness: 40,
},
],
};
// Chart options
const options = {
maintainAspectRatio: false,
plugins: {
datalabels: {
color: 'white',
anchor: 'start' as const,
align: 'right' as const,
offset: 20,
textStrokeColor: 'black',
textStrokeWidth: 4,
formatter: function(value: string) {
const hours = parseFloat(value);
const wholeHours = Math.floor(hours);
const minutes = Math.round((hours - wholeHours) * 60);
if (wholeHours === 0 && minutes === 0) {
return '0 mins';
} else if (wholeHours === 0) {
return `${minutes} mins`;
} else if (minutes === 0) {
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
if (utilizationPercent < 90) {
return '#faad14'; // Orange for under-utilized (< 90%)
} else if (utilizationPercent <= 110) {
return '#52c41a'; // Green for optimal utilization (90-110%)
} else {
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
return '#ef4444'; // Red for over-utilized (> 110%)
}
},
},
legend: {
display: false,
position: 'top' as const,
},
tooltip: {
// Basic styling
backgroundColor: themeMode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: themeMode === 'dark' ? '#ffffff' : '#000000',
bodyColor: themeMode === 'dark' ? '#ffffff' : '#000000',
borderColor: themeMode === 'dark' ? '#4a5568' : '#e2e8f0',
cornerRadius: 8,
padding: 12,
// Remove colored squares
displayColors: false,
// Positioning - better alignment for horizontal bar chart
xAlign: 'left' as const,
yAlign: 'center' as const,
callbacks: {
// Customize the title (member name)
title: function (context: any) {
const idx = context[0].dataIndex;
const member = jsonData[idx];
return `👤 ${member?.name || 'Unknown Member'}`;
},
// Customize the label content
label: function (context: any) {
const idx = context.dataIndex;
const member = jsonData[idx];
const hours = parseFloat(member?.utilized_hours || '0');
const percent = parseFloat(member?.utilization_percent || '0.00');
const overUnder = parseFloat(member?.over_under_utilized_hours || '0');
// Color indicators based on utilization state
let statusText = '';
let criteriaText = '';
switch (member.utilization_state) {
case 'under':
statusText = '🟠 Under-Utilized';
criteriaText = '(< 90%)';
break;
case 'optimal':
statusText = '🟢 Optimally Utilized';
criteriaText = '(90% - 110%)';
break;
case 'over':
statusText = '🔴 Over-Utilized';
criteriaText = '(> 110%)';
break;
default:
statusText = '⚪ Unknown';
criteriaText = '';
}
return [
`⏱️ ${context.dataset.label}: ${formatHours(hours)}`,
`📊 Utilization: ${percent.toFixed(1)}%`,
`${statusText} ${criteriaText}`,
`📈 Variance: ${formatHours(Math.abs(overUnder))}${overUnder < 0 ? ' (under)' : overUnder > 0 ? ' (over)' : ''}`
];
},
// Add a footer with additional info
footer: function (context: any) {
const idx = context[0].dataIndex;
const member = jsonData[idx];
const loggedTime = parseFloat(member?.logged_time || '0') / 3600;
return `📊 Total Logged: ${formatHours(loggedTime)}`;
}
}
}
},
backgroundColor: 'black',
indexAxis: 'y' as const,
responsive: true,
scales: {
x: {
title: {
display: true,
text: t('loggedTime'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
y: {
title: {
display: true,
text: t('member'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
},
};
})
: [];
const fetchChartData = async () => {
try {
setLoading(true);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const selectedTeams = teams.filter(team => team.selected);
const selectedProjects = filterProjects.filter(project => project.selected);
const selectedCategories = categories.filter(category => category.selected);
const selectedMembers = members.filter(member => member.selected);
const selectedUtilization = utilization.filter(item => item.selected);
// Helper function to format hours to "X hours Y mins"
const formatHours = (decimalHours: number) => {
const wholeHours = Math.floor(decimalHours);
const minutes = Math.round((decimalHours - wholeHours) * 60);
// Format dates using date-fns
const formattedDateRange = dateRange ? [
format(new Date(dateRange[0]), 'yyyy-MM-dd'),
format(new Date(dateRange[1]), 'yyyy-MM-dd')
] : undefined;
const body = {
teams: selectedTeams.map(t => t.id),
projects: selectedProjects.map(project => project.id),
categories: selectedCategories.map(category => category.id),
members: selectedMembers.map(member => member.id),
utilization: selectedUtilization.map(item => item.id),
duration,
date_range: formattedDateRange,
billable,
noCategory,
};
const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived);
if (res.done) {
// Ensure filteredRows is always an array, even if API returns null/undefined
setJsonData(res.body?.filteredRows || []);
const totalsRaw = res.body?.totals || {};
const totals = {
total_time_logs: totalsRaw.total_time_logs ?? "0",
total_estimated_hours: totalsRaw.total_estimated_hours ?? "0",
total_utilization: totalsRaw.total_utilization ?? "0",
};
onTotalsUpdate(totals);
if (wholeHours === 0 && minutes === 0) {
return '0 mins';
} else if (wholeHours === 0) {
return `${minutes} mins`;
} else if (minutes === 0) {
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
} else {
// Handle API error case
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
}
};
// Chart data
const data = {
labels,
datasets: [
{
label: t('loggedTime'),
data: dataValues,
backgroundColor: colors,
barThickness: 40,
},
],
};
// Chart options
const options = {
maintainAspectRatio: false,
plugins: {
datalabels: {
color: 'white',
anchor: 'start' as const,
align: 'right' as const,
offset: 20,
textStrokeColor: 'black',
textStrokeWidth: 4,
formatter: function (value: string) {
const hours = parseFloat(value);
const wholeHours = Math.floor(hours);
const minutes = Math.round((hours - wholeHours) * 60);
if (wholeHours === 0 && minutes === 0) {
return '0 mins';
} else if (wholeHours === 0) {
return `${minutes} mins`;
} else if (minutes === 0) {
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
} else {
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
}
},
},
legend: {
display: false,
position: 'top' as const,
},
tooltip: {
// Basic styling
backgroundColor: themeMode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)',
titleColor: themeMode === 'dark' ? '#ffffff' : '#000000',
bodyColor: themeMode === 'dark' ? '#ffffff' : '#000000',
borderColor: themeMode === 'dark' ? '#4a5568' : '#e2e8f0',
cornerRadius: 8,
padding: 12,
// Remove colored squares
displayColors: false,
// Positioning - better alignment for horizontal bar chart
xAlign: 'left' as const,
yAlign: 'center' as const,
callbacks: {
// Customize the title (member name)
title: function (context: any) {
const idx = context[0].dataIndex;
const member = jsonData[idx];
return `👤 ${member?.name || 'Unknown Member'}`;
},
// Customize the label content
label: function (context: any) {
const idx = context.dataIndex;
const member = jsonData[idx];
const hours = parseFloat(member?.utilized_hours || '0');
const percent = parseFloat(member?.utilization_percent || '0.00');
const overUnder = parseFloat(member?.over_under_utilized_hours || '0');
// Color indicators based on utilization state
let statusText = '';
let criteriaText = '';
switch (member.utilization_state) {
case 'under':
statusText = '🟠 Under-Utilized';
criteriaText = '(< 90%)';
break;
case 'optimal':
statusText = '🟢 Optimally Utilized';
criteriaText = '(90% - 110%)';
break;
case 'over':
statusText = '🔴 Over-Utilized';
criteriaText = '(> 110%)';
break;
default:
statusText = '⚪ Unknown';
criteriaText = '';
}
return [
`⏱️ ${context.dataset.label}: ${formatHours(hours)}`,
`📊 Utilization: ${percent.toFixed(1)}%`,
`${statusText} ${criteriaText}`,
`📈 Variance: ${formatHours(Math.abs(overUnder))}${overUnder < 0 ? ' (under)' : overUnder > 0 ? ' (over)' : ''}`,
];
},
// Add a footer with additional info
footer: function (context: any) {
const idx = context[0].dataIndex;
const member = jsonData[idx];
const loggedTime = parseFloat(member?.logged_time || '0') / 3600;
return `📊 Total Logged: ${formatHours(loggedTime)}`;
},
},
},
},
backgroundColor: 'black',
indexAxis: 'y' as const,
responsive: true,
scales: {
x: {
title: {
display: true,
text: t('loggedTime'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
y: {
title: {
display: true,
text: t('member'),
align: 'end' as const,
font: {
family: 'Helvetica',
},
},
grid: {
color: themeMode === 'dark' ? '#2c2f38' : '#e5e5e5',
lineWidth: 1,
},
},
},
};
const fetchChartData = async () => {
try {
setLoading(true);
const selectedTeams = teams.filter(team => team.selected);
const selectedProjects = filterProjects.filter(project => project.selected);
const selectedCategories = categories.filter(category => category.selected);
const selectedMembers = members.filter(member => member.selected);
const selectedUtilization = utilization.filter(item => item.selected);
// Format dates using date-fns
const formattedDateRange = dateRange
? [
format(new Date(dateRange[0]), 'yyyy-MM-dd'),
format(new Date(dateRange[1]), 'yyyy-MM-dd'),
]
: undefined;
const body = {
teams: selectedTeams.map(t => t.id),
projects: selectedProjects.map(project => project.id),
categories: selectedCategories.map(category => category.id),
members: selectedMembers.map(member => member.id),
utilization: selectedUtilization.map(item => item.id),
duration,
date_range: formattedDateRange,
billable,
noCategory,
};
const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived);
if (res.done) {
// Ensure filteredRows is always an array, even if API returns null/undefined
setJsonData(res.body?.filteredRows || []);
const totalsRaw = res.body?.totals || {};
const totals = {
total_time_logs: totalsRaw.total_time_logs ?? '0',
total_estimated_hours: totalsRaw.total_estimated_hours ?? '0',
total_utilization: totalsRaw.total_utilization ?? '0',
};
onTotalsUpdate(totals);
} else {
// Handle API error case
setJsonData([]);
onTotalsUpdate({
total_time_logs: '0',
total_estimated_hours: '0',
total_utilization: '0',
});
}
} catch (error) {
console.error('Error fetching chart data:', error);
logger.error('Error fetching chart data:', error);
// Reset data on error
setJsonData([]);
onTotalsUpdate({
total_time_logs: "0",
total_estimated_hours: "0",
total_utilization: "0"
total_time_logs: '0',
total_estimated_hours: '0',
total_utilization: '0',
});
} finally {
setLoading(false);
}
} catch (error) {
console.error('Error fetching chart data:', error);
logger.error('Error fetching chart data:', error);
// Reset data on error
setJsonData([]);
onTotalsUpdate({
total_time_logs: "0",
total_estimated_hours: "0",
total_utilization: "0"
});
} finally {
setLoading(false);
}
};
};
// Create stable references for selected items to prevent unnecessary re-renders
const selectedTeamIds = React.useMemo(() =>
teams.filter(team => team.selected).map(t => t.id).join(','),
[teams]
);
const selectedProjectIds = React.useMemo(() =>
filterProjects.filter(project => project.selected).map(p => p.id).join(','),
[filterProjects]
);
const selectedCategoryIds = React.useMemo(() =>
categories.filter(category => category.selected).map(c => c.id).join(','),
[categories]
);
const selectedMemberIds = React.useMemo(() =>
members.filter(member => member.selected).map(m => m.id).join(','),
[members]
);
const selectedUtilizationIds = React.useMemo(() =>
utilization.filter(item => item.selected).map(u => u.id).join(','),
[utilization]
);
// Create stable references for selected items to prevent unnecessary re-renders
const selectedTeamIds = React.useMemo(
() =>
teams
.filter(team => team.selected)
.map(t => t.id)
.join(','),
[teams]
);
useEffect(() => {
fetchChartData();
}, [duration, dateRange, billable, archived, noCategory, selectedTeamIds, selectedProjectIds, selectedCategoryIds, selectedMemberIds, selectedUtilizationIds]);
const selectedProjectIds = React.useMemo(
() =>
filterProjects
.filter(project => project.selected)
.map(p => p.id)
.join(','),
[filterProjects]
);
const exportChart = () => {
if (chartRef.current) {
// Get the canvas element
const canvas = chartRef.current.canvas;
const selectedCategoryIds = React.useMemo(
() =>
categories
.filter(category => category.selected)
.map(c => c.id)
.join(','),
[categories]
);
// Create a temporary canvas to draw with background
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) return;
const selectedMemberIds = React.useMemo(
() =>
members
.filter(member => member.selected)
.map(m => m.id)
.join(','),
[members]
);
// Set dimensions
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
const selectedUtilizationIds = React.useMemo(
() =>
utilization
.filter(item => item.selected)
.map(u => u.id)
.join(','),
[utilization]
);
// Fill background based on theme
tempCtx.fillStyle = themeMode === 'dark' ? '#1f1f1f' : '#ffffff';
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
useEffect(() => {
fetchChartData();
}, [
duration,
dateRange,
billable,
archived,
noCategory,
selectedTeamIds,
selectedProjectIds,
selectedCategoryIds,
selectedMemberIds,
selectedUtilizationIds,
]);
// Draw the original chart on top
tempCtx.drawImage(canvas, 0, 0);
const exportChart = () => {
if (chartRef.current) {
// Get the canvas element
const canvas = chartRef.current.canvas;
// Create download link
const link = document.createElement('a');
link.download = 'members-time-sheet.png';
link.href = tempCanvas.toDataURL('image/png');
link.click();
}
};
// Create a temporary canvas to draw with background
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) return;
useImperativeHandle(ref, () => ({
exportChart
}));
// Set dimensions
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
return (
<div style={{ position: 'relative' }}>
<div
style={{
maxWidth: 'calc(100vw - 220px)',
minWidth: 'calc(100vw - 260px)',
minHeight: 'calc(100vh - 300px)',
height: `${60 * data.labels.length}px`,
}}
>
<Bar data={data} options={options} ref={chartRef} />
// Fill background based on theme
tempCtx.fillStyle = themeMode === 'dark' ? '#1f1f1f' : '#ffffff';
tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
// Draw the original chart on top
tempCtx.drawImage(canvas, 0, 0);
// Create download link
const link = document.createElement('a');
link.download = 'members-time-sheet.png';
link.href = tempCanvas.toDataURL('image/png');
link.click();
}
};
useImperativeHandle(ref, () => ({
exportChart,
}));
return (
<div style={{ position: 'relative' }}>
<div
style={{
maxWidth: 'calc(100vw - 220px)',
minWidth: 'calc(100vw - 260px)',
minHeight: 'calc(100vh - 300px)',
height: `${60 * data.labels.length}px`,
}}
>
<Bar data={data} options={options} ref={chartRef} />
</div>
</div>
</div>
);
});
);
}
);
MembersTimeSheet.displayName = 'MembersTimeSheet';
export default MembersTimeSheet;
export default MembersTimeSheet;

View File

@@ -20,21 +20,23 @@ import { Empty, Spin } from '@/shared/antd-imports';
import logger from '@/utils/errorLogger';
// Lazy load the Bar chart component
const LazyBarChart = lazy(() =>
const LazyBarChart = lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
// Chart loading fallback
const ChartLoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '400px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '400px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0',
}}
>
<Spin size="large" />
</div>
);
@@ -43,7 +45,15 @@ const ChartLoadingFallback = () => (
let isChartJSRegistered = false;
const registerChartJS = () => {
if (!isChartJSRegistered) {
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
ChartDataLabels
);
isChartJSRegistered = true;
}
};

View File

@@ -1,5 +1,7 @@
import { Card, Flex } from 'antd';
import MembersTimeSheet, { MembersTimeSheetRef } from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
import MembersTimeSheet, {
MembersTimeSheetRef,
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useRef, useState } from 'react';
@@ -7,15 +9,17 @@ import { IRPTTimeTotals } from '@/types/reporting/reporting.types';
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
import TotalTimeUtilization from '@/components/reporting/time-reports/total-time-utilization/total-time-utilization';
import { useAppSelector } from '@/hooks/useAppSelector';
const MembersTimeReports = () => {
const { t } = useTranslation('time-report');
const chartRef = useRef<MembersTimeSheetRef>(null);
const [totals, setTotals] = useState<IRPTTimeTotals>({
total_time_logs: "0",
total_estimated_hours: "0",
total_utilization: "0",
total_time_logs: '0',
total_estimated_hours: '0',
total_utilization: '0',
});
const { dateRange } = useAppSelector(state => state.reportingReducer);
useDocumentTitle('Reporting - Allocation');
const handleExport = (type: string) => {
@@ -35,7 +39,7 @@ const MembersTimeReports = () => {
exportType={[{ key: 'png', label: 'PNG' }]}
export={handleExport}
/>
<TotalTimeUtilization totals={totals} />
<TotalTimeUtilization totals={totals} dateRange={dateRange} />
<Card
style={{ borderRadius: '4px' }}
title={

View File

@@ -1,4 +1,12 @@
import { Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from '@/shared/antd-imports';
import {
Button,
Card,
Popconfirm,
Table,
TableProps,
Tooltip,
Typography,
} from '@/shared/antd-imports';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';

View File

@@ -1,4 +1,12 @@
import { Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from '@/shared/antd-imports';
import {
Button,
Card,
Popconfirm,
Table,
TableProps,
Tooltip,
Typography,
} from '@/shared/antd-imports';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import './task-templates-settings.css';