feat(reporting): enhance time reports page with new filters and components
- Added new components for filtering by billable status, categories, projects, members, and teams in the time reports overview. - Implemented a new header component to manage the layout and functionality of the time reports page. - Refactored existing components to improve organization and maintainability, including the removal of deprecated files. - Updated localization files to support new UI elements and ensure consistency across languages. - Adjusted the language selector to reflect the correct language codes for Chinese.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import { setSelectOrDeselectBillable } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
Space,
|
||||
Divider,
|
||||
theme,
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Billable: React.FC = () => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const dispatch = useAppDispatch();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const { billable } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
|
||||
// Calculate active filters count
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (billable.billable) count++;
|
||||
if (billable.nonBillable) count++;
|
||||
return count;
|
||||
}, [billable.billable, billable.nonBillable]);
|
||||
|
||||
// Check if all options are selected
|
||||
const isAllSelected = billable.billable && billable.nonBillable;
|
||||
const isNoneSelected = !billable.billable && !billable.nonBillable;
|
||||
|
||||
// Handle select all
|
||||
const handleSelectAll = () => {
|
||||
dispatch(
|
||||
setSelectOrDeselectBillable({
|
||||
billable: true,
|
||||
nonBillable: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Handle clear all
|
||||
const handleClearAll = () => {
|
||||
dispatch(
|
||||
setSelectOrDeselectBillable({
|
||||
billable: false,
|
||||
nonBillable: false,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Theme-aware colors matching improved task filters
|
||||
const isDark = token.colorBgContainer !== '#ffffff';
|
||||
const colors = {
|
||||
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
// Dropdown items for the menu
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'header',
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 4px 2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
color: colors.headerText,
|
||||
borderBottom: `1px solid ${colors.borderColor}`,
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('filterByBillableStatus')}
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: (
|
||||
<div style={{ padding: '2px 4px', marginBottom: '2px' }}>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleSelectAll}
|
||||
disabled={isAllSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||
}}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleClearAll}
|
||||
disabled={isNoneSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||
}}
|
||||
>
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'billable',
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={billable.billable} style={{ fontSize: '14px' }}>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{t('billable')}</span>
|
||||
</Checkbox>
|
||||
{billable.billable && (
|
||||
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
onClick: () => {
|
||||
dispatch(setSelectOrDeselectBillable({ ...billable, billable: !billable.billable }));
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'nonBillable',
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={billable.nonBillable} style={{ fontSize: '14px' }}>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{t('nonBillable')}</span>
|
||||
</Checkbox>
|
||||
{billable.nonBillable && (
|
||||
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
onClick: () => {
|
||||
dispatch(setSelectOrDeselectBillable({ ...billable, nonBillable: !billable.nonBillable }));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Button text based on selection state
|
||||
const getButtonText = () => {
|
||||
if (isNoneSelected) return t('billable');
|
||||
if (isAllSelected) return t('allBillableTypes');
|
||||
if (billable.billable && !billable.nonBillable) return t('billable');
|
||||
if (!billable.billable && billable.nonBillable) return t('nonBillable');
|
||||
return t('billable');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
overlayStyle={{
|
||||
maxHeight: '330px',
|
||||
overflowY: 'auto',
|
||||
boxShadow: isDark
|
||||
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${colors.dropdownBorder}`,
|
||||
backgroundColor: colors.dropdownBg,
|
||||
}}
|
||||
overlayClassName="billable-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
height: '30px',
|
||||
fontSize: '12px',
|
||||
borderColor: colors.buttonBorder,
|
||||
color: colors.buttonText,
|
||||
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||
}}
|
||||
>
|
||||
<FilterOutlined
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
<span>{getButtonText()}</span>
|
||||
<CaretDownFilled
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginLeft: '2px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billable;
|
||||
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
fetchReportingProjects,
|
||||
setNoCategory,
|
||||
setSelectOrDeselectAllCategories,
|
||||
setSelectOrDeselectCategory,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
theme,
|
||||
Space,
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckboxChangeEvent
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Categories: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
const { t } = useTranslation('time-report');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const { categories, loadingCategories, noCategory } = useAppSelector(
|
||||
state => state.timeReportsOverviewReducer
|
||||
);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Calculate active filters count
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
const selectedCategories = categories.filter(category => category.selected).length;
|
||||
return selectedCategories + (noCategory ? 1 : 0);
|
||||
}, [categories, noCategory]);
|
||||
|
||||
// Check if all options are selected
|
||||
const isAllSelected =
|
||||
categories.length > 0 && categories.every(category => category.selected) && noCategory;
|
||||
const isNoneSelected =
|
||||
categories.length > 0 && !categories.some(category => category.selected) && !noCategory;
|
||||
|
||||
const filteredItems = categories.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// Theme-aware colors matching improved task filters
|
||||
const isDark = token.colorBgContainer !== '#ffffff';
|
||||
const colors = {
|
||||
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
// Handle checkbox change for individual items
|
||||
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
||||
await dispatch(setSelectOrDeselectCategory({ id: key, selected: checked }));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
await dispatch(setNoCategory(isChecked));
|
||||
await dispatch(setSelectOrDeselectAllCategories(isChecked));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
// Handle select all button click
|
||||
const handleSelectAllClick = async () => {
|
||||
const newValue = !isAllSelected;
|
||||
setSelectAll(newValue);
|
||||
await dispatch(setNoCategory(newValue));
|
||||
await dispatch(setSelectOrDeselectAllCategories(newValue));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
// Handle clear all
|
||||
const handleClearAll = async () => {
|
||||
setSelectAll(false);
|
||||
await dispatch(setNoCategory(false));
|
||||
await dispatch(setSelectOrDeselectAllCategories(false));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
const handleNoCategoryChange = async (checked: boolean) => {
|
||||
await dispatch(setNoCategory(checked));
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isNoneSelected) return t('categories');
|
||||
if (isAllSelected) return `All ${t('categories')}`;
|
||||
return `${t('categories')} (${activeFiltersCount})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={undefined}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div
|
||||
style={{
|
||||
background: colors.dropdownBg,
|
||||
borderRadius: '8px',
|
||||
boxShadow: isDark
|
||||
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||
border: `1px solid ${colors.dropdownBorder}`,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 4px 2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
color: colors.headerText,
|
||||
borderBottom: `1px solid ${colors.borderColor}`,
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('searchByCategory')}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
onClick={e => e.stopPropagation()}
|
||||
placeholder={t('searchByCategory')}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
style={{ fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleSelectAllClick}
|
||||
disabled={isAllSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||
}}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleClearAll}
|
||||
disabled={isNoneSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||
}}
|
||||
>
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Category Option */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={noCategory}
|
||||
onChange={e => handleNoCategoryChange(e.target.checked)}
|
||||
style={{ fontSize: '14px' }}
|
||||
>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{t('noCategory')}</span>
|
||||
</Checkbox>
|
||||
{noCategory && (
|
||||
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Items */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={item.selected}
|
||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||
style={{ fontSize: '14px' }}
|
||||
>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{item.name}</span>
|
||||
</Checkbox>
|
||||
{item.selected && (
|
||||
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '4px 8px', fontSize: '14px', color: colors.headerText }}>
|
||||
{t('noCategories')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={visible => {
|
||||
setDropdownVisible(visible);
|
||||
if (!visible) {
|
||||
setSearchText('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
loading={loadingCategories}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
height: '30px',
|
||||
fontSize: '12px',
|
||||
borderColor: colors.buttonBorder,
|
||||
color: colors.buttonText,
|
||||
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||
}}
|
||||
>
|
||||
<FilterOutlined
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
<span>{getButtonText()}</span>
|
||||
<CaretDownFilled
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginLeft: '2px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Avatar,
|
||||
theme,
|
||||
Space,
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckboxChangeEvent,
|
||||
} from '@/shared/antd-imports';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
setSelectOrDeselectAllMembers,
|
||||
setSelectOrDeselectMember,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
const Members: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('time-report');
|
||||
const { members, loadingMembers } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
|
||||
// Calculate active filters count
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
return members.filter(member => member.selected).length;
|
||||
}, [members]);
|
||||
|
||||
// Check if all options are selected
|
||||
const isAllSelected = members.length > 0 && members.every(member => member.selected);
|
||||
const isNoneSelected = members.length > 0 && !members.some(member => member.selected);
|
||||
|
||||
// Filter members based on search text
|
||||
const filteredMembers = members.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// Theme-aware colors matching improved task filters
|
||||
const isDark = token.colorBgContainer !== '#ffffff';
|
||||
const colors = {
|
||||
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
// Handle checkbox change for individual members
|
||||
const handleCheckboxChange = (id: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectMember({ id, selected: checked }));
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
dispatch(setSelectOrDeselectAllMembers(isChecked));
|
||||
};
|
||||
|
||||
// Handle select all button click
|
||||
const handleSelectAllClick = () => {
|
||||
const newValue = !isAllSelected;
|
||||
setSelectAll(newValue);
|
||||
dispatch(setSelectOrDeselectAllMembers(newValue));
|
||||
};
|
||||
|
||||
// Handle clear all
|
||||
const handleClearAll = () => {
|
||||
setSelectAll(false);
|
||||
dispatch(setSelectOrDeselectAllMembers(false));
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isNoneSelected) return t('members');
|
||||
if (isAllSelected) return `All ${t('members')}`;
|
||||
return `${t('members')} (${activeFiltersCount})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={undefined}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div
|
||||
style={{
|
||||
background: colors.dropdownBg,
|
||||
borderRadius: '8px',
|
||||
boxShadow: isDark
|
||||
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||
border: `1px solid ${colors.dropdownBorder}`,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 4px 2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
color: colors.headerText,
|
||||
borderBottom: `1px solid ${colors.borderColor}`,
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('searchByMember')}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
onClick={e => e.stopPropagation()}
|
||||
placeholder={t('searchByMember')}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
style={{ fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleSelectAllClick}
|
||||
disabled={isAllSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||
}}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleClearAll}
|
||||
disabled={isNoneSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||
}}
|
||||
>
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Items */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredMembers.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Avatar src={member.avatar_url} alt={member.name} size="small" />
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={member.selected}
|
||||
onChange={e => handleCheckboxChange(member.id, e.target.checked)}
|
||||
style={{ fontSize: '14px' }}
|
||||
>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{member.name}</span>
|
||||
</Checkbox>
|
||||
{member.selected && (
|
||||
<CheckCircleFilled
|
||||
style={{ color: colors.successColor, fontSize: '10px', marginLeft: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
loading={loadingMembers}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
height: '30px',
|
||||
fontSize: '12px',
|
||||
borderColor: colors.buttonBorder,
|
||||
color: colors.buttonText,
|
||||
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||
}}
|
||||
>
|
||||
<FilterOutlined
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
<span>{getButtonText()}</span>
|
||||
<CaretDownFilled
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginLeft: '2px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default Members;
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
theme,
|
||||
Space,
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckboxChangeEvent,
|
||||
} from '@/shared/antd-imports';
|
||||
import {
|
||||
setSelectOrDeselectAllProjects,
|
||||
setSelectOrDeselectProject,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const Projects: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [checkedList, setCheckedList] = useState<string[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
const { t } = useTranslation('time-report');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// Calculate active filters count
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
return projects.filter(project => project.selected).length;
|
||||
}, [projects]);
|
||||
|
||||
// Check if all options are selected
|
||||
const isAllSelected = projects.length > 0 && projects.every(project => project.selected);
|
||||
const isNoneSelected = projects.length > 0 && !projects.some(project => project.selected);
|
||||
|
||||
// Filter items based on search text
|
||||
const filteredItems = projects.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// Theme-aware colors matching improved task filters
|
||||
const isDark = token.colorBgContainer !== '#ffffff';
|
||||
const colors = {
|
||||
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
// Handle checkbox change for individual items
|
||||
const handleCheckboxChange = (key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||
};
|
||||
|
||||
// Handle "Select All" checkbox change
|
||||
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||
};
|
||||
|
||||
// Handle select all button click
|
||||
const handleSelectAllClick = () => {
|
||||
const newValue = !isAllSelected;
|
||||
setSelectAll(newValue);
|
||||
dispatch(setSelectOrDeselectAllProjects(newValue));
|
||||
};
|
||||
|
||||
// Handle clear all
|
||||
const handleClearAll = () => {
|
||||
setSelectAll(false);
|
||||
dispatch(setSelectOrDeselectAllProjects(false));
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isNoneSelected) return t('projects');
|
||||
if (isAllSelected) return `All ${t('projects')}`;
|
||||
return `${t('projects')} (${activeFiltersCount})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={undefined}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div
|
||||
style={{
|
||||
background: colors.dropdownBg,
|
||||
borderRadius: '8px',
|
||||
boxShadow: isDark
|
||||
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||
border: `1px solid ${colors.dropdownBorder}`,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 4px 2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
color: colors.headerText,
|
||||
borderBottom: `1px solid ${colors.borderColor}`,
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('searchByProject')}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
onClick={e => e.stopPropagation()}
|
||||
placeholder={t('searchByProject')}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
style={{ fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleSelectAllClick}
|
||||
disabled={isAllSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||
}}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleClearAll}
|
||||
disabled={isNoneSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||
}}
|
||||
>
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Items */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={item.selected}
|
||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||
style={{ fontSize: '14px' }}
|
||||
>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{item.name}</span>
|
||||
</Checkbox>
|
||||
{item.selected && (
|
||||
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={visible => {
|
||||
setDropdownVisible(visible);
|
||||
if (!visible) {
|
||||
setSearchText('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
loading={loadingProjects}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
height: '30px',
|
||||
fontSize: '12px',
|
||||
borderColor: colors.buttonBorder,
|
||||
color: colors.buttonText,
|
||||
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||
}}
|
||||
>
|
||||
<FilterOutlined
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
<span>{getButtonText()}</span>
|
||||
<CaretDownFilled
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginLeft: '2px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
theme,
|
||||
Space,
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckboxChangeEvent,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
fetchReportingCategories,
|
||||
fetchReportingProjects,
|
||||
setSelectOrDeselectAllTeams,
|
||||
setSelectOrDeselectTeam,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
|
||||
const Team: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
const { t } = useTranslation('time-report');
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const { teams, loadingTeams } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
|
||||
// Calculate active filters count
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
return teams.filter(team => team.selected).length;
|
||||
}, [teams]);
|
||||
|
||||
// Check if all options are selected
|
||||
const isAllSelected = teams.length > 0 && teams.every(team => team.selected);
|
||||
const isNoneSelected = teams.length > 0 && !teams.some(team => team.selected);
|
||||
|
||||
const filteredItems = teams.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// Theme-aware colors matching improved task filters
|
||||
const isDark = token.colorBgContainer !== '#ffffff';
|
||||
const colors = {
|
||||
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
||||
dispatch(setSelectOrDeselectTeam({ id: key, selected: checked }));
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
dispatch(setSelectOrDeselectAllTeams(isChecked));
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
// Handle clear all
|
||||
const handleClearAll = async () => {
|
||||
setSelectAll(false);
|
||||
dispatch(setSelectOrDeselectAllTeams(false));
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
// Handle select all button click
|
||||
const handleSelectAllClick = async () => {
|
||||
const newValue = !isAllSelected;
|
||||
setSelectAll(newValue);
|
||||
dispatch(setSelectOrDeselectAllTeams(newValue));
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isNoneSelected) return t('teams');
|
||||
if (isAllSelected) return `All ${t('teams')}`;
|
||||
return `${t('teams')} (${activeFiltersCount})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={undefined}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div
|
||||
style={{
|
||||
background: colors.dropdownBg,
|
||||
borderRadius: '8px',
|
||||
boxShadow: isDark
|
||||
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||
border: `1px solid ${colors.dropdownBorder}`,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 4px 2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
color: colors.headerText,
|
||||
borderBottom: `1px solid ${colors.borderColor}`,
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('searchByName')}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||
<Input
|
||||
placeholder={t('searchByName')}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ fontSize: '14px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleSelectAllClick}
|
||||
disabled={isAllSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||
}}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleClearAll}
|
||||
disabled={isNoneSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||
}}
|
||||
>
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Items */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={item.selected}
|
||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||
style={{ fontSize: '14px' }}
|
||||
>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{item.name}</span>
|
||||
</Checkbox>
|
||||
{item.selected && (
|
||||
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onOpenChange={visible => {
|
||||
setDropdownVisible(visible);
|
||||
if (!visible) {
|
||||
setSearchText('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
loading={loadingTeams}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
height: '30px',
|
||||
fontSize: '12px',
|
||||
borderColor: colors.buttonBorder,
|
||||
color: colors.buttonText,
|
||||
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||
}}
|
||||
>
|
||||
<FilterOutlined
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
<span>{getButtonText()}</span>
|
||||
<CaretDownFilled
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginLeft: '2px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Team;
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Team from './Team';
|
||||
import Categories from './Categories';
|
||||
import Projects from './Projects';
|
||||
import Billable from './Billable';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
fetchReportingTeams,
|
||||
fetchReportingProjects,
|
||||
fetchReportingCategories,
|
||||
fetchReportingMembers,
|
||||
fetchReportingUtilization,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import Members from './Members';
|
||||
import Utilization from './Utilization';
|
||||
|
||||
const TimeReportPageHeader: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if current route is members time sheet
|
||||
const isMembersTimeSheet = location.pathname.includes('time-sheet-members');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await dispatch(fetchReportingTeams());
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
|
||||
// Only fetch members and utilization data for members time sheet
|
||||
if (isMembersTimeSheet) {
|
||||
await dispatch(fetchReportingMembers());
|
||||
await dispatch(fetchReportingUtilization());
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [dispatch, isMembersTimeSheet]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Team />
|
||||
<Categories />
|
||||
<Projects />
|
||||
<Billable />
|
||||
{isMembersTimeSheet && <Members/>}
|
||||
{isMembersTimeSheet && <Utilization />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeReportPageHeader;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
setSelectOrDeselectAllUtilization,
|
||||
setSelectOrDeselectUtilization,
|
||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Avatar,
|
||||
theme,
|
||||
Space,
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckboxChangeEvent,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Utilization: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('time-report');
|
||||
const { utilization, loadingUtilization } = useAppSelector(
|
||||
state => state.timeReportsOverviewReducer
|
||||
);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
|
||||
// Calculate active filters count
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
return utilization.filter(item => item.selected).length;
|
||||
}, [utilization]);
|
||||
|
||||
// Check if all options are selected
|
||||
const isAllSelected = utilization.length > 0 && utilization.every(item => item.selected);
|
||||
const isNoneSelected = utilization.length > 0 && !utilization.some(item => item.selected);
|
||||
|
||||
// Filter members based on search text
|
||||
const filteredItems = utilization.filter(item =>
|
||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// Theme-aware colors matching improved task filters
|
||||
const isDark = token.colorBgContainer !== '#ffffff';
|
||||
const colors = {
|
||||
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
buttonText: activeFiltersCount > 0
|
||||
? (isDark ? 'white' : '#262626')
|
||||
: (isDark ? '#d9d9d9' : '#595959'),
|
||||
buttonBg: activeFiltersCount > 0
|
||||
? (isDark ? '#434343' : '#f5f5f5')
|
||||
: (isDark ? '#141414' : 'white'),
|
||||
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||
};
|
||||
|
||||
// Handle checkbox change for individual members
|
||||
const handleCheckboxChange = (id: string, selected: boolean) => {
|
||||
dispatch(setSelectOrDeselectUtilization({ id, selected }));
|
||||
};
|
||||
|
||||
const handleSelectAll = (e: CheckboxChangeEvent) => {
|
||||
const isChecked = e.target.checked;
|
||||
setSelectAll(isChecked);
|
||||
dispatch(setSelectOrDeselectAllUtilization(isChecked));
|
||||
};
|
||||
|
||||
// Handle select all button click
|
||||
const handleSelectAllClick = () => {
|
||||
const newValue = !isAllSelected;
|
||||
setSelectAll(newValue);
|
||||
dispatch(setSelectOrDeselectAllUtilization(newValue));
|
||||
};
|
||||
|
||||
// Handle clear all
|
||||
const handleClearAll = () => {
|
||||
setSelectAll(false);
|
||||
dispatch(setSelectOrDeselectAllUtilization(false));
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isNoneSelected) return t('utilization');
|
||||
if (isAllSelected) return `All ${t('utilization')}`;
|
||||
return `${t('utilization')} (${activeFiltersCount})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={undefined}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div
|
||||
style={{
|
||||
background: colors.dropdownBg,
|
||||
borderRadius: '8px',
|
||||
boxShadow: isDark
|
||||
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||
border: `1px solid ${colors.dropdownBorder}`,
|
||||
padding: '4px 0',
|
||||
maxHeight: '330px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 4px 2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '12px',
|
||||
color: colors.headerText,
|
||||
borderBottom: `1px solid ${colors.borderColor}`,
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{t('utilization')}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleSelectAllClick}
|
||||
disabled={isAllSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||
}}
|
||||
>
|
||||
{t('selectAll')}
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleClearAll}
|
||||
disabled={isNoneSelected}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
height: 'auto',
|
||||
fontSize: '11px',
|
||||
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||
}}
|
||||
>
|
||||
{t('clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||
|
||||
{/* Items */}
|
||||
<div
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{filteredItems.map((ut, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={ut.selected}
|
||||
onChange={e => handleCheckboxChange(ut.id, e.target.checked)}
|
||||
style={{ fontSize: '14px' }}
|
||||
>
|
||||
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{ut.name}</span>
|
||||
</Checkbox>
|
||||
{ut.selected && (
|
||||
<CheckCircleFilled
|
||||
style={{ color: colors.successColor, fontSize: '10px', marginLeft: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
loading={loadingUtilization}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
height: '30px',
|
||||
fontSize: '12px',
|
||||
borderColor: colors.buttonBorder,
|
||||
color: colors.buttonText,
|
||||
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
backgroundColor: colors.buttonBg,
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||
}}
|
||||
>
|
||||
<FilterOutlined
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
<span>{getButtonText()}</span>
|
||||
<CaretDownFilled
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginLeft: '2px',
|
||||
color: colors.buttonText,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default Utilization;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, Dropdown, Space, Typography } from '@/shared/antd-imports';
|
||||
import { DownOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TimeWiseFilter from '@/components/reporting/time-wise-filter';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { setArchived } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||
import CustomPageHeader from '@/pages/reporting/page-header/custom-page-header';
|
||||
|
||||
interface headerState {
|
||||
title: string;
|
||||
exportType: Array<{ key: string; label: string }>;
|
||||
export: (key: string) => void;
|
||||
}
|
||||
|
||||
const TimeReportingRightHeader: React.FC<headerState> = ({
|
||||
title,
|
||||
exportType,
|
||||
export: exportFn,
|
||||
}) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const dispatch = useAppDispatch();
|
||||
const { archived } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||
|
||||
const menuItems = exportType.map(item => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
onClick: () => exportFn(item.key),
|
||||
}));
|
||||
|
||||
return (
|
||||
<CustomPageHeader
|
||||
title={title}
|
||||
children={
|
||||
<Space>
|
||||
<Button>
|
||||
<Checkbox checked={archived} onChange={e => dispatch(setArchived(e.target.checked))}>
|
||||
<Typography.Text>{t('includeArchivedProjects')}</Typography.Text>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
<TimeWiseFilter />
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
{t('export')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeReportingRightHeader;
|
||||
Reference in New Issue
Block a user