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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from './settings';
|
||||
export { default } from './settings';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -26,5 +26,3 @@
|
||||
[data-theme="dark"] .project-view-tabs .ant-tabs-ink-bar {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user