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:
@@ -21,11 +21,11 @@ interface AssigneeSelectorProps {
|
||||
kanbanMode?: boolean;
|
||||
}
|
||||
|
||||
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
task,
|
||||
groupId = null,
|
||||
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
task,
|
||||
groupId = null,
|
||||
isDarkMode = false,
|
||||
kanbanMode = false
|
||||
kanbanMode = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -63,8 +63,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
// Close dropdown when clicking outside and handle scroll
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -74,10 +78,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
// Check if the button is still visible in the viewport
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const isVisible = rect.top >= 0 && rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth;
|
||||
|
||||
const isVisible =
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth;
|
||||
|
||||
if (isVisible) {
|
||||
updateDropdownPosition();
|
||||
} else {
|
||||
@@ -98,7 +104,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
@@ -113,10 +119,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
const handleDropdownToggle = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
if (!isOpen) {
|
||||
updateDropdownPosition();
|
||||
|
||||
|
||||
// Prepare team members data when opening
|
||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||
const membersData = (members?.data || []).map(member => ({
|
||||
@@ -125,7 +131,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
}));
|
||||
const sortedMembers = sortTeamMembers(membersData);
|
||||
setTeamMembers({ data: sortedMembers });
|
||||
|
||||
|
||||
setIsOpen(true);
|
||||
// Focus search input after opening
|
||||
setTimeout(() => {
|
||||
@@ -160,11 +166,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
// Update local team members state for dropdown UI
|
||||
setTeamMembers(prev => ({
|
||||
...prev,
|
||||
data: (prev.data || []).map(member =>
|
||||
member.id === memberId
|
||||
? { ...member, selected: checked }
|
||||
: member
|
||||
)
|
||||
data: (prev.data || []).map(member =>
|
||||
member.id === memberId ? { ...member, selected: checked } : member
|
||||
),
|
||||
}));
|
||||
|
||||
const body = {
|
||||
@@ -178,12 +182,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
|
||||
// Emit socket event - the socket handler will update Redux with proper types
|
||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||
socket?.once(
|
||||
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
|
||||
(data: any) => {
|
||||
dispatch(updateEnhancedKanbanTaskAssignees(data));
|
||||
}
|
||||
);
|
||||
socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => {
|
||||
dispatch(updateEnhancedKanbanTaskAssignees(data));
|
||||
});
|
||||
|
||||
// Remove from pending changes after a short delay (optimistic)
|
||||
setTimeout(() => {
|
||||
@@ -198,9 +199,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
const checkMemberSelected = (memberId: string) => {
|
||||
if (!memberId) return false;
|
||||
// Use optimistic assignees if available, otherwise fall back to task assignees
|
||||
const assignees = optimisticAssignees.length > 0
|
||||
? optimisticAssignees
|
||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
||||
const assignees =
|
||||
optimisticAssignees.length > 0
|
||||
? optimisticAssignees
|
||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
||||
return assignees.includes(memberId);
|
||||
};
|
||||
|
||||
@@ -217,149 +219,159 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||
className={`
|
||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||
transition-colors duration-200
|
||||
${isOpen
|
||||
? isDarkMode
|
||||
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||
${
|
||||
isOpen
|
||||
? isDarkMode
|
||||
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||
: isDarkMode
|
||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<PlusOutlined className="text-xs" />
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={`
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className={`
|
||||
fixed z-[99999] w-72 rounded-md shadow-lg border
|
||||
${isDarkMode
|
||||
? 'bg-gray-800 border-gray-600'
|
||||
: 'bg-white border-gray-200'
|
||||
}
|
||||
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
|
||||
`}
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search members..."
|
||||
className={`
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search members..."
|
||||
className={`
|
||||
w-full px-2 py-1 text-xs rounded border
|
||||
${isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||
${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||
}
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredMembers && filteredMembers.length > 0 ? (
|
||||
filteredMembers.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`
|
||||
{/* Members List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredMembers && filteredMembers.length > 0 ? (
|
||||
filteredMembers.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`
|
||||
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||
${member.pending_invitation
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700'
|
||||
: 'hover:bg-gray-50'
|
||||
${
|
||||
member.pending_invitation
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700'
|
||||
: 'hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (!member.pending_invitation) {
|
||||
const isSelected = checkMemberSelected(member.id || '');
|
||||
handleMemberToggle(member.id || '', !isSelected);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// Add visual feedback for immediate response
|
||||
transition: 'all 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<span onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkMemberSelected(member.id || '')}
|
||||
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
||||
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</span>
|
||||
{pendingChanges.has(member.id || '') && (
|
||||
<div className={`absolute inset-0 flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
||||
}`}>
|
||||
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar
|
||||
src={member.avatar_url}
|
||||
name={member.name || ''}
|
||||
size={24}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||
{member.name}
|
||||
</div>
|
||||
<div className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{member.email}
|
||||
{member.pending_invitation && (
|
||||
<span className="text-red-400 ml-1">(Pending)</span>
|
||||
onClick={() => {
|
||||
if (!member.pending_invitation) {
|
||||
const isSelected = checkMemberSelected(member.id || '');
|
||||
handleMemberToggle(member.id || '', !isSelected);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// Add visual feedback for immediate response
|
||||
transition: 'all 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<span onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkMemberSelected(member.id || '')}
|
||||
onChange={checked => handleMemberToggle(member.id || '', checked)}
|
||||
disabled={
|
||||
member.pending_invitation || pendingChanges.has(member.id || '')
|
||||
}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</span>
|
||||
{pendingChanges.has(member.id || '') && (
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center ${
|
||||
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<div className="text-xs">No members found</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||
<button
|
||||
className={`
|
||||
<Avatar
|
||||
src={member.avatar_url}
|
||||
name={member.name || ''}
|
||||
size={24}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
|
||||
>
|
||||
{member.name}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
{member.email}
|
||||
{member.pending_invitation && (
|
||||
<span className="text-red-400 ml-1">(Pending)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
<div className="text-xs">No members found</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||
<button
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||
transition-colors
|
||||
${isDarkMode
|
||||
? 'text-blue-400 hover:bg-gray-700'
|
||||
: 'text-blue-600 hover:bg-blue-50'
|
||||
}
|
||||
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
|
||||
`}
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
>
|
||||
<UserAddOutlined />
|
||||
Invite member
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
>
|
||||
<UserAddOutlined />
|
||||
Invite member
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssigneeSelector;
|
||||
export default AssigneeSelector;
|
||||
|
||||
@@ -15,20 +15,20 @@ const CustomColordLabel = React.forwardRef<HTMLSpanElement, CustomColordLabelPro
|
||||
|
||||
// Handle different color property names for different types
|
||||
const backgroundColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color
|
||||
|
||||
|
||||
// Function to determine if we should use white or black text based on background color
|
||||
const getTextColor = (bgColor: string): string => {
|
||||
// Remove # if present
|
||||
const color = bgColor.replace('#', '');
|
||||
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(color.substr(0, 2), 16);
|
||||
const g = parseInt(color.substr(2, 2), 16);
|
||||
const b = parseInt(color.substr(4, 2), 16);
|
||||
|
||||
|
||||
// Calculate luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
|
||||
// Return white for dark backgrounds, black for light backgrounds
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
};
|
||||
@@ -40,7 +40,7 @@ const CustomColordLabel = React.forwardRef<HTMLSpanElement, CustomColordLabelPro
|
||||
<span
|
||||
ref={ref}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium shrink-0 max-w-[100px]"
|
||||
style={{
|
||||
style={{
|
||||
backgroundColor,
|
||||
color: textColor,
|
||||
border: `1px solid ${backgroundColor}`,
|
||||
|
||||
@@ -12,11 +12,13 @@ interface CustomNumberLabelProps {
|
||||
const CustomNumberLabel = React.forwardRef<HTMLSpanElement, CustomNumberLabelProps>(
|
||||
({ labelList, namesString, isDarkMode = false, color }, ref) => {
|
||||
// Use provided color, or fall back to NumbersColorMap based on first digit
|
||||
const backgroundColor = color || (() => {
|
||||
const firstDigit = namesString.match(/\d/)?.[0] || '0';
|
||||
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
|
||||
})();
|
||||
|
||||
const backgroundColor =
|
||||
color ||
|
||||
(() => {
|
||||
const firstDigit = namesString.match(/\d/)?.[0] || '0';
|
||||
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
|
||||
})();
|
||||
|
||||
return (
|
||||
<Tooltip title={labelList.join(', ')}>
|
||||
<span
|
||||
|
||||
@@ -44,10 +44,10 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
|
||||
const dropdownHeight = 300; // Approximate height of dropdown (max-height + padding)
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
|
||||
// Position dropdown above button if there's not enough space below
|
||||
const shouldPositionAbove = spaceBelow < dropdownHeight && spaceAbove > dropdownHeight;
|
||||
|
||||
|
||||
if (shouldPositionAbove) {
|
||||
setDropdownPosition({
|
||||
top: rect.top + window.scrollY - dropdownHeight - 2,
|
||||
@@ -228,7 +228,7 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
|
||||
flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors
|
||||
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
|
||||
`}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleLabelToggle(label);
|
||||
}}
|
||||
@@ -281,7 +281,9 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = fals
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||
<div className={`flex items-center justify-center gap-1 px-2 py-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<div
|
||||
className={`flex items-center justify-center gap-1 px-2 py-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
<TagOutlined />
|
||||
{t('manageLabelsPath')}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ class ModuleErrorBoundary extends Component<Props, State> {
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
// Check if this is a module loading error
|
||||
const isModuleError =
|
||||
const isModuleError =
|
||||
error.message.includes('Failed to fetch dynamically imported module') ||
|
||||
error.message.includes('Loading chunk') ||
|
||||
error.message.includes('Loading CSS chunk') ||
|
||||
@@ -35,7 +35,7 @@ class ModuleErrorBoundary extends Component<Props, State> {
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Module Error Boundary caught an error:', error, errorInfo);
|
||||
|
||||
|
||||
// If this is a module loading error, clear caches and reload
|
||||
if (this.state.hasError) {
|
||||
this.handleModuleError();
|
||||
@@ -45,10 +45,10 @@ class ModuleErrorBoundary extends Component<Props, State> {
|
||||
private async handleModuleError() {
|
||||
try {
|
||||
console.log('Handling module loading error - clearing caches...');
|
||||
|
||||
|
||||
// Clear all caches
|
||||
await CacheCleanup.clearAllCaches();
|
||||
|
||||
|
||||
// Force reload to login page
|
||||
CacheCleanup.forceReload('/auth/login');
|
||||
} catch (cacheError) {
|
||||
@@ -71,32 +71,26 @@ class ModuleErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="error"
|
||||
title="Module Loading Error"
|
||||
subTitle="There was an issue loading the application. This usually happens after updates or during logout."
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
key="retry"
|
||||
onClick={this.handleRetry}
|
||||
loading={false}
|
||||
>
|
||||
<Button type="primary" key="retry" onClick={this.handleRetry} loading={false}>
|
||||
Retry
|
||||
</Button>,
|
||||
<Button
|
||||
key="reload"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Button key="reload" onClick={() => window.location.reload()}>
|
||||
Reload Page
|
||||
</Button>
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -107,4 +101,4 @@ class ModuleErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
export default ModuleErrorBoundary;
|
||||
export default ModuleErrorBoundary;
|
||||
|
||||
@@ -74,10 +74,12 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
||||
if (res.done && res.body.id) {
|
||||
toggleTemplateSelector(false);
|
||||
trackMixpanelEvent(evt_account_setup_template_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));
|
||||
@@ -85,7 +87,7 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh user session after template setup completion', error);
|
||||
}
|
||||
|
||||
|
||||
navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -69,7 +69,7 @@ const CurrentPlanDetails = () => {
|
||||
);
|
||||
|
||||
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
|
||||
const seatCountOptions: SeatOption[] = useMemo(() => {
|
||||
const options: SeatOption[] = [
|
||||
1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
|
||||
@@ -78,30 +78,33 @@ const CurrentPlanDetails = () => {
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const handleSubscriptionAction = useCallback(async (action: SubscriptionAction) => {
|
||||
const isResume = action === 'resume';
|
||||
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
|
||||
const apiMethod = isResume
|
||||
? adminCenterApiService.resumeSubscription
|
||||
: adminCenterApiService.pauseSubscription;
|
||||
const eventType = isResume ? evt_billing_resume_plan : evt_billing_pause_plan;
|
||||
const handleSubscriptionAction = useCallback(
|
||||
async (action: SubscriptionAction) => {
|
||||
const isResume = action === 'resume';
|
||||
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
|
||||
const apiMethod = isResume
|
||||
? adminCenterApiService.resumeSubscription
|
||||
: adminCenterApiService.pauseSubscription;
|
||||
const eventType = isResume ? evt_billing_resume_plan : evt_billing_pause_plan;
|
||||
|
||||
try {
|
||||
setLoadingState(true);
|
||||
const res = await apiMethod();
|
||||
if (res.done) {
|
||||
setTimeout(() => {
|
||||
setLoadingState(false);
|
||||
dispatch(fetchBillingInfo());
|
||||
trackMixpanelEvent(eventType);
|
||||
}, BILLING_DELAY_MS);
|
||||
return;
|
||||
try {
|
||||
setLoadingState(true);
|
||||
const res = await apiMethod();
|
||||
if (res.done) {
|
||||
setTimeout(() => {
|
||||
setLoadingState(false);
|
||||
dispatch(fetchBillingInfo());
|
||||
trackMixpanelEvent(eventType);
|
||||
}, BILLING_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error ${action}ing subscription`, error);
|
||||
setLoadingState(false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error ${action}ing subscription`, error);
|
||||
setLoadingState(false);
|
||||
}
|
||||
}, [dispatch, trackMixpanelEvent]);
|
||||
},
|
||||
[dispatch, trackMixpanelEvent]
|
||||
);
|
||||
|
||||
const handleAddMoreSeats = useCallback(() => {
|
||||
setIsMoreSeatsModalVisible(true);
|
||||
@@ -137,17 +140,17 @@ const CurrentPlanDetails = () => {
|
||||
const getDefaultSeatCount = useMemo(() => {
|
||||
const currentUsed = billingInfo?.total_used || 0;
|
||||
const availableSeats = calculateRemainingSeats;
|
||||
|
||||
|
||||
// If only 1 user and no available seats, suggest 1 additional seat
|
||||
if (currentUsed === 1 && availableSeats === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
// If they have some users but running low on seats, suggest enough for current users
|
||||
if (availableSeats < currentUsed && currentUsed > 0) {
|
||||
return Math.max(1, currentUsed - availableSeats);
|
||||
}
|
||||
|
||||
|
||||
// Default fallback
|
||||
return Math.max(1, Math.min(5, currentUsed || 1));
|
||||
}, [billingInfo?.total_used, calculateRemainingSeats]);
|
||||
@@ -157,10 +160,13 @@ const CurrentPlanDetails = () => {
|
||||
setSelectedSeatCount(getDefaultSeatCount);
|
||||
}, [getDefaultSeatCount]);
|
||||
|
||||
const checkSubscriptionStatus = useCallback((allowedStatuses: string[]) => {
|
||||
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
|
||||
return allowedStatuses.includes(billingInfo.status);
|
||||
}, [billingInfo?.status, billingInfo?.is_ltd_user]);
|
||||
const checkSubscriptionStatus = useCallback(
|
||||
(allowedStatuses: string[]) => {
|
||||
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
|
||||
return allowedStatuses.includes(billingInfo.status);
|
||||
},
|
||||
[billingInfo?.status, billingInfo?.is_ltd_user]
|
||||
);
|
||||
|
||||
const shouldShowRedeemButton = useMemo(() => {
|
||||
if (billingInfo?.trial_in_progress) return true;
|
||||
@@ -261,28 +267,31 @@ const CurrentPlanDetails = () => {
|
||||
return today > trialExpireDate;
|
||||
}, [billingInfo?.trial_expire_date]);
|
||||
|
||||
const getExpirationMessage = useCallback((expireDate: string) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const getExpirationMessage = useCallback(
|
||||
(expireDate: string) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const expDate = new Date(expireDate);
|
||||
expDate.setHours(0, 0, 0, 0);
|
||||
const expDate = new Date(expireDate);
|
||||
expDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (expDate.getTime() === today.getTime()) {
|
||||
return t('expirestoday', 'today');
|
||||
} else if (expDate.getTime() === tomorrow.getTime()) {
|
||||
return t('expirestomorrow', 'tomorrow');
|
||||
} else if (expDate < today) {
|
||||
const diffTime = Math.abs(today.getTime() - expDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
|
||||
} else {
|
||||
return calculateTimeGap(expireDate);
|
||||
}
|
||||
}, [t]);
|
||||
if (expDate.getTime() === today.getTime()) {
|
||||
return t('expirestoday', 'today');
|
||||
} else if (expDate.getTime() === tomorrow.getTime()) {
|
||||
return t('expirestomorrow', 'tomorrow');
|
||||
} else if (expDate < today) {
|
||||
const diffTime = Math.abs(today.getTime() - expDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
|
||||
} else {
|
||||
return calculateTimeGap(expireDate);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const renderTrialDetails = useCallback(() => {
|
||||
const isExpired = checkIfTrialExpired();
|
||||
@@ -309,19 +318,22 @@ const CurrentPlanDetails = () => {
|
||||
);
|
||||
}, [billingInfo?.trial_expire_date, checkIfTrialExpired, getExpirationMessage, t]);
|
||||
|
||||
const renderFreePlan = useCallback(() => (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>{t('freePlan')}</Typography.Text>
|
||||
<Typography.Text>
|
||||
<br />-{' '}
|
||||
{freePlanSettings?.team_member_limit === 0
|
||||
? t('unlimitedTeamMembers')
|
||||
: `${freePlanSettings?.team_member_limit} ${t('teamMembers')}`}
|
||||
<br />- {freePlanSettings?.projects_limit} {t('projects')}
|
||||
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
), [freePlanSettings, t]);
|
||||
const renderFreePlan = useCallback(
|
||||
() => (
|
||||
<Flex vertical>
|
||||
<Typography.Text strong>{t('freePlan')}</Typography.Text>
|
||||
<Typography.Text>
|
||||
<br />-{' '}
|
||||
{freePlanSettings?.team_member_limit === 0
|
||||
? t('unlimitedTeamMembers')
|
||||
: `${freePlanSettings?.team_member_limit} ${t('teamMembers')}`}
|
||||
<br />- {freePlanSettings?.projects_limit} {t('projects')}
|
||||
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
[freePlanSettings, t]
|
||||
);
|
||||
|
||||
const renderPaddleSubscriptionInfo = useCallback(() => {
|
||||
return (
|
||||
@@ -439,9 +451,7 @@ const CurrentPlanDetails = () => {
|
||||
extra={renderExtra()}
|
||||
>
|
||||
<Flex vertical>
|
||||
<div style={{ marginBottom: '14px' }}>
|
||||
{renderSubscriptionContent()}
|
||||
</div>
|
||||
<div style={{ marginBottom: '14px' }}>{renderSubscriptionContent()}</div>
|
||||
|
||||
{shouldShowRedeemButton && (
|
||||
<>
|
||||
@@ -478,10 +488,12 @@ const CurrentPlanDetails = () => {
|
||||
<Typography.Paragraph
|
||||
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
|
||||
>
|
||||
{billingInfo?.total_used === 1
|
||||
? t('purchaseSeatsTextSingle', "Add more seats to invite team members to your workspace.")
|
||||
: t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")
|
||||
}
|
||||
{billingInfo?.total_used === 1
|
||||
? t(
|
||||
'purchaseSeatsTextSingle',
|
||||
'Add more seats to invite team members to your workspace.'
|
||||
)
|
||||
: t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
||||
@@ -497,9 +509,11 @@ const CurrentPlanDetails = () => {
|
||||
|
||||
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
|
||||
{billingInfo?.total_used === 1
|
||||
? t('selectSeatsTextSingle', 'Select how many additional seats you need for new team members.')
|
||||
: t('selectSeatsText', 'Please select the number of additional seats to purchase.')
|
||||
}
|
||||
? t(
|
||||
'selectSeatsTextSingle',
|
||||
'Select how many additional seats you need for new team members.'
|
||||
)
|
||||
: t('selectSeatsText', 'Please select the number of additional seats to purchase.')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
@@ -511,7 +525,6 @@ const CurrentPlanDetails = () => {
|
||||
options={seatCountOptions}
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<Flex justify="end">
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { Button, Card, Col, Form, Input, notification, Row, Tag, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
notification,
|
||||
Row,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useState } from 'react';
|
||||
import './upgrade-plans-lkr.css';
|
||||
import { CheckCircleFilled } from '@/shared/antd-imports';
|
||||
|
||||
@@ -41,4 +41,4 @@
|
||||
.upgrade-plans-responsive .ant-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +516,9 @@ const UpgradePlans = () => {
|
||||
>
|
||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||
? t('changeToPlan', 'Change to {{plan}}', { plan: t('annualPlan', 'Annual Plan') })
|
||||
: t('continueWith', 'Continue with {{plan}}', { plan: t('annualPlan', 'Annual Plan') })}
|
||||
: t('continueWith', 'Continue with {{plan}}', {
|
||||
plan: t('annualPlan', 'Annual Plan'),
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{selectedPlan === paddlePlans.MONTHLY && (
|
||||
@@ -529,7 +531,9 @@ const UpgradePlans = () => {
|
||||
>
|
||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||
? t('changeToPlan', 'Change to {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })
|
||||
: t('continueWith', 'Continue with {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })}
|
||||
: t('continueWith', 'Continue with {{plan}}', {
|
||||
plan: t('monthlyPlan', 'Monthly Plan'),
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.holiday-calendar.dark .ant-picker-calendar-date {
|
||||
@@ -62,6 +63,12 @@
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.holiday-cell .ant-tag:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.holiday-calendar .ant-picker-calendar-date-today {
|
||||
@@ -211,7 +218,10 @@
|
||||
/* Card styles */
|
||||
.holiday-calendar .ant-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
box-shadow:
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.03),
|
||||
0 1px 6px -1px rgba(0, 0, 0, 0.02),
|
||||
0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.holiday-calendar.dark .ant-card {
|
||||
@@ -258,13 +268,13 @@
|
||||
height: 60px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
|
||||
.holiday-calendar .ant-picker-calendar-date-value {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
.holiday-cell .ant-tag {
|
||||
font-size: 9px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Calendar, Card, Typography, Button, Modal, Form, Input, Select, DatePicker, Switch, Space, Tag, Popconfirm, message } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, GlobalOutlined } from '@ant-design/icons';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Card,
|
||||
Typography,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
Switch,
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { holidayApiService } from '@/api/holiday/holiday.api.service';
|
||||
import {
|
||||
IHolidayType,
|
||||
IOrganizationHoliday,
|
||||
IAvailableCountry,
|
||||
ICreateHolidayRequest,
|
||||
IUpdateHolidayRequest,
|
||||
IHolidayCalendarEvent,
|
||||
} from '@/types/holiday/holiday.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { RootState } from '@/app/store';
|
||||
import { fetchHolidays } from '@/features/admin-center/admin-center.slice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import './holiday-calendar.css';
|
||||
|
||||
@@ -24,17 +42,17 @@ interface HolidayCalendarProps {
|
||||
|
||||
const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
const { t } = useTranslation('admin-center/overview');
|
||||
const dispatch = useAppDispatch();
|
||||
const { holidays, loadingHolidays, holidaySettings } = useAppSelector(
|
||||
(state: RootState) => state.adminCenterReducer
|
||||
);
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const [holidayTypes, setHolidayTypes] = useState<IHolidayType[]>([]);
|
||||
const [organizationHolidays, setOrganizationHolidays] = useState<IOrganizationHoliday[]>([]);
|
||||
const [availableCountries, setAvailableCountries] = useState<IAvailableCountry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||
const [selectedHoliday, setSelectedHoliday] = useState<IOrganizationHoliday | null>(null);
|
||||
const [selectedHoliday, setSelectedHoliday] = useState<IHolidayCalendarEvent | null>(null);
|
||||
const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs());
|
||||
|
||||
const fetchHolidayTypes = async () => {
|
||||
@@ -48,37 +66,28 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchOrganizationHolidays = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await holidayApiService.getOrganizationHolidays(currentDate.year());
|
||||
if (res.done) {
|
||||
setOrganizationHolidays(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organization holidays', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchHolidaysForDateRange = () => {
|
||||
const startOfYear = currentDate.startOf('year');
|
||||
const endOfYear = currentDate.endOf('year');
|
||||
|
||||
const fetchAvailableCountries = async () => {
|
||||
try {
|
||||
const res = await holidayApiService.getAvailableCountries();
|
||||
if (res.done) {
|
||||
setAvailableCountries(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching available countries', error);
|
||||
}
|
||||
dispatch(
|
||||
fetchHolidays({
|
||||
from_date: startOfYear.format('YYYY-MM-DD'),
|
||||
to_date: endOfYear.format('YYYY-MM-DD'),
|
||||
include_custom: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHolidayTypes();
|
||||
fetchOrganizationHolidays();
|
||||
fetchAvailableCountries();
|
||||
fetchHolidaysForDateRange();
|
||||
}, [currentDate.year()]);
|
||||
|
||||
const customHolidays = useMemo(() => {
|
||||
return holidays.filter(holiday => holiday.source === 'custom');
|
||||
}, [holidays]);
|
||||
|
||||
const handleCreateHoliday = async (values: any) => {
|
||||
try {
|
||||
const holidayData: ICreateHolidayRequest = {
|
||||
@@ -94,7 +103,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
message.success(t('holidayCreated'));
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchOrganizationHolidays();
|
||||
fetchHolidaysForDateRange();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating holiday', error);
|
||||
@@ -115,13 +124,16 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
is_recurring: values.is_recurring,
|
||||
};
|
||||
|
||||
const res = await holidayApiService.updateOrganizationHoliday(selectedHoliday.id, holidayData);
|
||||
const res = await holidayApiService.updateOrganizationHoliday(
|
||||
selectedHoliday.id,
|
||||
holidayData
|
||||
);
|
||||
if (res.done) {
|
||||
message.success(t('holidayUpdated'));
|
||||
setEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
setSelectedHoliday(null);
|
||||
fetchOrganizationHolidays();
|
||||
fetchHolidaysForDateRange();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating holiday', error);
|
||||
@@ -134,7 +146,7 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
const res = await holidayApiService.deleteOrganizationHoliday(holidayId);
|
||||
if (res.done) {
|
||||
message.success(t('holidayDeleted'));
|
||||
fetchOrganizationHolidays();
|
||||
fetchHolidaysForDateRange();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting holiday', error);
|
||||
@@ -142,53 +154,47 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCountryHolidays = async (values: any) => {
|
||||
try {
|
||||
const res = await holidayApiService.importCountryHolidays({
|
||||
country_code: values.country_code,
|
||||
year: values.year || currentDate.year(),
|
||||
});
|
||||
if (res.done) {
|
||||
message.success(t('holidaysImported', { count: res.body.imported_count }));
|
||||
setImportModalVisible(false);
|
||||
fetchOrganizationHolidays();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error importing country holidays', error);
|
||||
message.error(t('errorImportingHolidays'));
|
||||
const handleEditHoliday = (holiday: IHolidayCalendarEvent) => {
|
||||
// Only allow editing custom holidays
|
||||
if (holiday.source !== 'custom' || !holiday.is_editable) {
|
||||
message.warning(t('cannotEditOfficialHoliday') || 'Cannot edit official holidays');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditHoliday = (holiday: IOrganizationHoliday) => {
|
||||
setSelectedHoliday(holiday);
|
||||
editForm.setFieldsValue({
|
||||
name: holiday.name,
|
||||
description: holiday.description,
|
||||
date: dayjs(holiday.date),
|
||||
holiday_type_id: holiday.holiday_type_id,
|
||||
holiday_type_id: holiday.holiday_type_name, // This might need adjustment based on backend
|
||||
is_recurring: holiday.is_recurring,
|
||||
});
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const getHolidayDateCellRender = (date: Dayjs) => {
|
||||
const holiday = organizationHolidays.find(h => dayjs(h.date).isSame(date, 'day'));
|
||||
|
||||
if (holiday) {
|
||||
const holidayType = holidayTypes.find(ht => ht.id === holiday.holiday_type_id);
|
||||
const dateHolidays = holidays.filter(h => dayjs(h.date).isSame(date, 'day'));
|
||||
|
||||
if (dateHolidays.length > 0) {
|
||||
return (
|
||||
<div className="holiday-cell">
|
||||
<Tag
|
||||
color={holidayType?.color_code || '#f37070'}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '1px 4px',
|
||||
margin: 0,
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
>
|
||||
{holiday.name}
|
||||
</Tag>
|
||||
{dateHolidays.map((holiday, index) => (
|
||||
<Tag
|
||||
key={`${holiday.id}-${index}`}
|
||||
color={holiday.color_code || (holiday.source === 'official' ? '#1890ff' : '#f37070')}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '1px 4px',
|
||||
margin: '1px 0',
|
||||
borderRadius: '2px',
|
||||
display: 'block',
|
||||
opacity: holiday.source === 'official' ? 0.8 : 1,
|
||||
}}
|
||||
title={`${holiday.name}${holiday.source === 'official' ? ' (Official)' : ' (Custom)'}`}
|
||||
>
|
||||
{holiday.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -199,36 +205,61 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
setCurrentDate(value);
|
||||
};
|
||||
|
||||
const onDateSelect = (date: Dayjs) => {
|
||||
// Check if there's already a custom holiday on this date
|
||||
const existingCustomHoliday = holidays.find(
|
||||
h => dayjs(h.date).isSame(date, 'day') && h.source === 'custom' && h.is_editable
|
||||
);
|
||||
|
||||
if (existingCustomHoliday) {
|
||||
// If custom holiday exists, open edit modal
|
||||
handleEditHoliday(existingCustomHoliday);
|
||||
} else {
|
||||
// If no custom holiday, open create modal with pre-filled date
|
||||
form.setFieldValue('date', date);
|
||||
setModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{t('holidayCalendar')}
|
||||
</Title>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<GlobalOutlined />}
|
||||
onClick={() => setImportModalVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('importCountryHolidays')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setModalVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
{t('addHoliday')}
|
||||
{t('addCustomHoliday') || 'Add Custom Holiday'}
|
||||
</Button>
|
||||
{holidaySettings?.country_code && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('officialHolidaysFrom') || 'Official holidays from'}:{' '}
|
||||
{holidaySettings.country_code}
|
||||
{holidaySettings.state_code && ` (${holidaySettings.state_code})`}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Calendar
|
||||
value={currentDate}
|
||||
onPanelChange={onPanelChange}
|
||||
onSelect={onDateSelect}
|
||||
dateCellRender={getHolidayDateCellRender}
|
||||
className={`holiday-calendar ${themeMode}`}
|
||||
loading={loadingHolidays}
|
||||
/>
|
||||
|
||||
{/* Create Holiday Modal */}
|
||||
@@ -272,14 +303,14 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
{holidayTypes.map(type => (
|
||||
<Option key={type.id} value={type.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: type.color_code,
|
||||
marginRight: 8
|
||||
}}
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{type.name}
|
||||
</div>
|
||||
@@ -297,10 +328,12 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('save')}
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
}}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -350,14 +383,14 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
{holidayTypes.map(type => (
|
||||
<Option key={type.id} value={type.id}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: type.color_code,
|
||||
marginRight: 8
|
||||
}}
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{type.name}
|
||||
</div>
|
||||
@@ -375,51 +408,13 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('update')}
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
setEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
setSelectedHoliday(null);
|
||||
}}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Import Country Holidays Modal */}
|
||||
<Modal
|
||||
title={t('importCountryHolidays')}
|
||||
open={importModalVisible}
|
||||
onCancel={() => setImportModalVisible(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form layout="vertical" onFinish={handleImportCountryHolidays}>
|
||||
<Form.Item
|
||||
name="country_code"
|
||||
label={t('country')}
|
||||
rules={[{ required: true, message: t('countryRequired') }]}
|
||||
>
|
||||
<Select placeholder={t('selectCountry')}>
|
||||
{availableCountries.map(country => (
|
||||
<Option key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="year" label={t('year')}>
|
||||
<DatePicker picker="year" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('import')}
|
||||
</Button>
|
||||
<Button onClick={() => setImportModalVisible(false)}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
setSelectedHoliday(null);
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -430,4 +425,4 @@ const HolidayCalendar: React.FC<HolidayCalendarProps> = ({ themeMode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default HolidayCalendar;
|
||||
export default HolidayCalendar;
|
||||
|
||||
@@ -15,7 +15,7 @@ interface OrganizationCalculationMethodProps {
|
||||
|
||||
const OrganizationCalculationMethod: React.FC<OrganizationCalculationMethodProps> = ({
|
||||
organization,
|
||||
refetch
|
||||
refetch,
|
||||
}) => {
|
||||
const { t } = useTranslation('admin-center/overview');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
@@ -42,7 +42,7 @@ const OrganizationCalculationMethod: React.FC<OrganizationCalculationMethodProps
|
||||
await adminCenterApiService.updateOrganizationCalculationMethod(currentMethod);
|
||||
|
||||
message.success(t('calculationMethodUpdated'));
|
||||
|
||||
|
||||
setHasChanges(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
@@ -65,7 +65,7 @@ const OrganizationCalculationMethod: React.FC<OrganizationCalculationMethodProps
|
||||
<InfoCircleOutlined style={{ color: '#666' }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space align="center" wrap>
|
||||
<Text strong>{t('calculationMethod')}:</Text>
|
||||
@@ -117,4 +117,4 @@ const OrganizationCalculationMethod: React.FC<OrganizationCalculationMethodProps
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationCalculationMethod;
|
||||
export default OrganizationCalculationMethod;
|
||||
|
||||
@@ -6,7 +6,16 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { SettingOutlined, DeleteOutlined } from '@/shared/antd-imports';
|
||||
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
@@ -17,7 +17,7 @@ const renderAvatar = (member: InlineMember, index: number, allowClickThrough: bo
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||
</span>
|
||||
) : (
|
||||
) : (
|
||||
<span onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar
|
||||
size={28}
|
||||
@@ -34,16 +34,18 @@ const renderAvatar = (member: InlineMember, index: number, allowClickThrough: bo
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount, allowClickThrough = false }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar.Group>
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const Avatars: React.FC<AvatarsProps> = React.memo(
|
||||
({ members, maxCount, allowClickThrough = false }) => {
|
||||
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
|
||||
return (
|
||||
<div onClick={allowClickThrough ? undefined : (e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<Avatar.Group>
|
||||
{visibleMembers.map((member, index) => renderAvatar(member, index, allowClickThrough))}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Avatars.displayName = 'Avatars';
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, Col, DatePicker, Divider, Flex, Row, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Avatar,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Flex,
|
||||
Row,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import StatusDropdown from '../../taskListCommon/statusDropdown/StatusDropdown';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -2,38 +2,40 @@ import { lazy, Suspense } from 'react';
|
||||
import { Spin } from '@/shared/antd-imports';
|
||||
|
||||
// Lazy load Chart.js components
|
||||
const LazyBarChart = lazy(() =>
|
||||
const LazyBarChart = lazy(() =>
|
||||
import('react-chartjs-2').then(module => ({ default: module.Bar }))
|
||||
);
|
||||
|
||||
const LazyLineChart = lazy(() =>
|
||||
const LazyLineChart = lazy(() =>
|
||||
import('react-chartjs-2').then(module => ({ default: module.Line }))
|
||||
);
|
||||
|
||||
const LazyPieChart = lazy(() =>
|
||||
const LazyPieChart = lazy(() =>
|
||||
import('react-chartjs-2').then(module => ({ default: module.Pie }))
|
||||
);
|
||||
|
||||
const LazyDoughnutChart = lazy(() =>
|
||||
const LazyDoughnutChart = lazy(() =>
|
||||
import('react-chartjs-2').then(module => ({ default: module.Doughnut }))
|
||||
);
|
||||
|
||||
// Lazy load Gantt components
|
||||
const LazyGanttChart = lazy(() =>
|
||||
const LazyGanttChart = lazy(() =>
|
||||
import('gantt-task-react').then(module => ({ default: module.Gantt }))
|
||||
);
|
||||
|
||||
// Chart loading fallback
|
||||
const ChartLoadingFallback = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '300px',
|
||||
background: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '300px',
|
||||
background: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
@@ -75,10 +77,10 @@ export const usePreloadCharts = () => {
|
||||
// Preload Chart.js
|
||||
import('react-chartjs-2');
|
||||
import('chart.js');
|
||||
|
||||
|
||||
// Preload Gantt
|
||||
import('gantt-task-react');
|
||||
};
|
||||
|
||||
return { preloadCharts };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Drawer,
|
||||
Flex,
|
||||
Form,
|
||||
message,
|
||||
Select,
|
||||
Spin,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
|
||||
@@ -64,11 +64,11 @@ const PeopleDropdown: React.FC<PeopleDropdownProps> = ({
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding
|
||||
|
||||
|
||||
// Check if dropdown would go below viewport
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||
|
||||
|
||||
setDropdownPosition({
|
||||
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
@@ -338,4 +338,4 @@ const PeopleDropdown: React.FC<PeopleDropdownProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default PeopleDropdown;
|
||||
export default PeopleDropdown;
|
||||
|
||||
@@ -7,7 +7,7 @@ interface TooltipWrapperProps extends Omit<TooltipProps, 'children'> {
|
||||
|
||||
/**
|
||||
* TooltipWrapper - A wrapper component that helps avoid findDOMNode warnings in React StrictMode
|
||||
*
|
||||
*
|
||||
* This component ensures that the child element can properly receive refs from Ant Design's Tooltip
|
||||
* by wrapping it in a div with a ref when necessary.
|
||||
*/
|
||||
@@ -25,4 +25,4 @@ const TooltipWrapper = React.forwardRef<HTMLDivElement, TooltipWrapperProps>(
|
||||
|
||||
TooltipWrapper.displayName = 'TooltipWrapper';
|
||||
|
||||
export default TooltipWrapper;
|
||||
export default TooltipWrapper;
|
||||
|
||||
100
worklenz-frontend/src/components/debug/HolidayDebugInfo.tsx
Normal file
100
worklenz-frontend/src/components/debug/HolidayDebugInfo.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Tag, Space } from 'antd';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { RootState } from '@/app/store';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface HolidayDebugInfoProps {
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
const HolidayDebugInfo: React.FC<HolidayDebugInfoProps> = ({ show = false }) => {
|
||||
const { holidays, loadingHolidays, holidaysDateRange, holidaySettings } = useAppSelector(
|
||||
(state: RootState) => state.adminCenterReducer
|
||||
);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title="Holiday Debug Info"
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px dashed #ccc',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong>Holiday Settings:</Text>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text>Country: {holidaySettings?.country_code || 'Not set'}</Text>
|
||||
<br />
|
||||
<Text>State: {holidaySettings?.state_code || 'Not set'}</Text>
|
||||
<br />
|
||||
<Text>Auto Sync: {holidaySettings?.auto_sync_holidays ? 'Yes' : 'No'}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Current Date Range:</Text>
|
||||
{holidaysDateRange ? (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text>From: {holidaysDateRange.from}</Text>
|
||||
<br />
|
||||
<Text>To: {holidaysDateRange.to}</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Text> Not loaded</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Holidays Loaded:</Text>
|
||||
<Space wrap style={{ marginLeft: 8 }}>
|
||||
{loadingHolidays ? (
|
||||
<Tag color="blue">Loading...</Tag>
|
||||
) : holidays.length > 0 ? (
|
||||
<>
|
||||
<Tag color="green">Total: {holidays.length}</Tag>
|
||||
<Tag color="orange">
|
||||
Official: {holidays.filter(h => h.source === 'official').length}
|
||||
</Tag>
|
||||
<Tag color="purple">
|
||||
Custom: {holidays.filter(h => h.source === 'custom').length}
|
||||
</Tag>
|
||||
</>
|
||||
) : (
|
||||
<Tag color="red">No holidays loaded</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{holidays.length > 0 && (
|
||||
<div>
|
||||
<Text strong>Recent Holidays:</Text>
|
||||
<div style={{ marginLeft: 8, maxHeight: 100, overflow: 'auto' }}>
|
||||
{holidays.slice(0, 5).map((holiday, index) => (
|
||||
<div key={`${holiday.id}-${index}`} style={{ fontSize: '11px' }}>
|
||||
<Tag size="small" color={holiday.source === 'official' ? 'blue' : 'orange'}>
|
||||
{holiday.source}
|
||||
</Tag>
|
||||
{dayjs(holiday.date).format('MMM DD')}: {holiday.name}
|
||||
</div>
|
||||
))}
|
||||
{holidays.length > 5 && (
|
||||
<Text type="secondary">... and {holidays.length - 5} more</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HolidayDebugInfo;
|
||||
@@ -8,7 +8,15 @@ import ImprovedTaskFilters from '../../task-management/improved-task-filters';
|
||||
import Card from 'antd/es/card';
|
||||
import Spin from 'antd/es/spin';
|
||||
import Empty from 'antd/es/empty';
|
||||
import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import {
|
||||
reorderGroups,
|
||||
reorderEnhancedKanbanGroups,
|
||||
reorderTasks,
|
||||
reorderEnhancedKanbanTasks,
|
||||
fetchEnhancedKanbanLabels,
|
||||
fetchEnhancedKanbanGroups,
|
||||
fetchEnhancedKanbanTaskAssignees,
|
||||
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import KanbanGroup from './KanbanGroup';
|
||||
@@ -29,18 +37,18 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
const project = useAppSelector((state: RootState) => state.projectReducer.project);
|
||||
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
|
||||
const teamId = authService.getCurrentSession()?.team_id;
|
||||
const {
|
||||
taskGroups,
|
||||
loadingGroups,
|
||||
error,
|
||||
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
|
||||
const { taskGroups, loadingGroups, error } = useSelector(
|
||||
(state: RootState) => state.enhancedKanbanReducer
|
||||
);
|
||||
const [draggedGroupId, setDraggedGroupId] = useState<string | null>(null);
|
||||
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
|
||||
const [draggedTaskGroupId, setDraggedTaskGroupId] = useState<string | null>(null);
|
||||
const [hoveredGroupId, setHoveredGroupId] = useState<string | null>(null);
|
||||
const [hoveredTaskIdx, setHoveredTaskIdx] = useState<number | null>(null);
|
||||
const [dragType, setDragType] = useState<'group' | 'task' | null>(null);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
|
||||
// Set up socket event handlers for real-time updates
|
||||
useTaskSocketHandlers();
|
||||
@@ -89,7 +97,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
const [moved] = reorderedGroups.splice(fromIdx, 1);
|
||||
reorderedGroups.splice(toIdx, 0, moved);
|
||||
dispatch(reorderGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }));
|
||||
dispatch(reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any);
|
||||
dispatch(
|
||||
reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any
|
||||
);
|
||||
|
||||
// API call for group order
|
||||
try {
|
||||
@@ -101,7 +111,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
|
||||
revertedGroups.splice(fromIdx, 0, movedBackGroup);
|
||||
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
|
||||
dispatch(
|
||||
reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })
|
||||
);
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -109,7 +121,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIdx, 1);
|
||||
revertedGroups.splice(fromIdx, 0, movedBackGroup);
|
||||
dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups }));
|
||||
dispatch(
|
||||
reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })
|
||||
);
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
logger.error('Failed to update column order', error);
|
||||
}
|
||||
@@ -135,12 +149,17 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
setHoveredTaskIdx(0);
|
||||
} else {
|
||||
setHoveredTaskIdx(taskIdx);
|
||||
};
|
||||
}
|
||||
};
|
||||
const handleTaskDrop = async (e: React.DragEvent, targetGroupId: string, targetTaskIdx: number | null) => {
|
||||
const handleTaskDrop = async (
|
||||
e: React.DragEvent,
|
||||
targetGroupId: string,
|
||||
targetTaskIdx: number | null
|
||||
) => {
|
||||
if (dragType !== 'task') return;
|
||||
e.preventDefault();
|
||||
if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null) return;
|
||||
if (!draggedTaskId || !draggedTaskGroupId || hoveredGroupId === null || hoveredTaskIdx === null)
|
||||
return;
|
||||
|
||||
// Calculate new order and dispatch
|
||||
const sourceGroup = taskGroups.find(g => g.id === draggedTaskGroupId);
|
||||
@@ -183,24 +202,28 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
|
||||
updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position
|
||||
|
||||
dispatch(reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
}));
|
||||
dispatch(reorderEnhancedKanbanTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
}) as any);
|
||||
dispatch(
|
||||
reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
reorderEnhancedKanbanTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks: updatedTasks,
|
||||
updatedTargetTasks: updatedTasks,
|
||||
}) as any
|
||||
);
|
||||
} else {
|
||||
// Handle cross-group reordering
|
||||
const updatedSourceTasks = [...sourceGroup.tasks];
|
||||
@@ -211,24 +234,28 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
if (insertIdx > updatedTargetTasks.length) insertIdx = updatedTargetTasks.length;
|
||||
updatedTargetTasks.splice(insertIdx, 0, movedTask);
|
||||
|
||||
dispatch(reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}));
|
||||
dispatch(reorderEnhancedKanbanTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}) as any);
|
||||
dispatch(
|
||||
reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
})
|
||||
);
|
||||
dispatch(
|
||||
reorderEnhancedKanbanTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: taskIdx,
|
||||
toIndex: insertIdx,
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
|
||||
// Socket emit for task order
|
||||
@@ -305,12 +332,24 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
</div>
|
||||
<div className="enhanced-kanban-board">
|
||||
{loadingGroups ? (
|
||||
<div className="flex flex-row gap-2 h-[600px]">
|
||||
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '60%' }} />
|
||||
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '100%' }} />
|
||||
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '80%' }} />
|
||||
<div className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4" style={{ height: '40%' }} />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 h-[600px]">
|
||||
<div
|
||||
className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4"
|
||||
style={{ height: '60%' }}
|
||||
/>
|
||||
<div
|
||||
className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4"
|
||||
style={{ height: '100%' }}
|
||||
/>
|
||||
<div
|
||||
className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4"
|
||||
style={{ height: '80%' }}
|
||||
/>
|
||||
<div
|
||||
className="rounded bg-gray-200 dark:bg-gray-700 animate-pulse w-1/4"
|
||||
style={{ height: '40%' }}
|
||||
/>
|
||||
</div>
|
||||
) : taskGroups.length === 0 ? (
|
||||
<Card>
|
||||
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
@@ -340,4 +379,4 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedKanbanBoardNativeDnD;
|
||||
export default EnhancedKanbanBoardNativeDnD;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,42 +14,46 @@ import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { toggleTaskExpansion, fetchBoardSubTasks } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import {
|
||||
toggleTaskExpansion,
|
||||
fetchBoardSubTasks,
|
||||
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import TaskProgressCircle from './TaskProgressCircle';
|
||||
|
||||
// Simple Portal component
|
||||
const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const portalRoot = document.getElementById('portal-root') || document.body;
|
||||
return createPortal(children, portalRoot);
|
||||
const portalRoot = document.getElementById('portal-root') || document.body;
|
||||
return createPortal(children, portalRoot);
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: IProjectTask;
|
||||
onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void;
|
||||
onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void;
|
||||
onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void;
|
||||
groupId: string;
|
||||
idx: number;
|
||||
onDragEnd: (e: React.DragEvent) => void; // <-- add this
|
||||
task: IProjectTask;
|
||||
onTaskDragStart: (e: React.DragEvent, taskId: string, groupId: string) => void;
|
||||
onTaskDragOver: (e: React.DragEvent, groupId: string, taskIdx: number) => void;
|
||||
onTaskDrop: (e: React.DragEvent, groupId: string, taskIdx: number) => void;
|
||||
groupId: string;
|
||||
idx: number;
|
||||
onDragEnd: (e: React.DragEvent) => void; // <-- add this
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number) {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstDayOfWeek(year: number, month: number) {
|
||||
return new Date(year, month, 1).getDay();
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
const TaskCard: React.FC<TaskCardProps> = memo(
|
||||
({
|
||||
task,
|
||||
onTaskDragStart,
|
||||
onTaskDragOver,
|
||||
onTaskDrop,
|
||||
groupId,
|
||||
idx,
|
||||
onDragEnd // <-- add this
|
||||
}) => {
|
||||
onDragEnd, // <-- add this
|
||||
}) => {
|
||||
const { socket } = useSocket();
|
||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||
const { projectId } = useSelector((state: RootState) => state.projectReducer);
|
||||
@@ -60,123 +64,136 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(
|
||||
task.end_date ? new Date(task.end_date) : null
|
||||
task.end_date ? new Date(task.end_date) : null
|
||||
);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||
const dateButtonRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(
|
||||
null
|
||||
);
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => {
|
||||
const d = selectedDate || new Date();
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
const d = selectedDate || new Date();
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDate(task.end_date ? new Date(task.end_date) : null);
|
||||
setSelectedDate(task.end_date ? new Date(task.end_date) : null);
|
||||
}, [task.end_date]);
|
||||
|
||||
// Close date picker when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showDatePicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
if (showDatePicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showDatePicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showDatePicker && dateButtonRef.current) {
|
||||
const rect = dateButtonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
}
|
||||
if (showDatePicker && dateButtonRef.current) {
|
||||
const rect = dateButtonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
}
|
||||
}, [showDatePicker]);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, [dispatch]);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleDateClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowDatePicker(true);
|
||||
e.stopPropagation();
|
||||
setShowDatePicker(true);
|
||||
}, []);
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
(date: Date | null) => {
|
||||
if (!task.id || !projectId) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
setSelectedDate(date);
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_END_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
end_date: date,
|
||||
parent_task: task.parent_task_id,
|
||||
time_zone: getUserSession()?.timezone_name
|
||||
? getUserSession()?.timezone_name
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update due date:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
},
|
||||
[task.id, projectId, socket]
|
||||
(date: Date | null) => {
|
||||
if (!task.id || !projectId) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
setSelectedDate(date);
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_END_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
end_date: date,
|
||||
parent_task: task.parent_task_id,
|
||||
time_zone: getUserSession()?.timezone_name
|
||||
? getUserSession()?.timezone_name
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update due date:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
},
|
||||
[task.id, projectId, socket]
|
||||
);
|
||||
|
||||
const handleClearDate = useCallback(() => {
|
||||
handleDateChange(null);
|
||||
handleDateChange(null);
|
||||
}, [handleDateChange]);
|
||||
|
||||
const handleToday = useCallback(() => {
|
||||
handleDateChange(new Date());
|
||||
handleDateChange(new Date());
|
||||
}, [handleDateChange]);
|
||||
|
||||
const handleTomorrow = useCallback(() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
handleDateChange(tomorrow);
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
handleDateChange(tomorrow);
|
||||
}, [handleDateChange]);
|
||||
|
||||
const handleNextWeek = useCallback(() => {
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
handleDateChange(nextWeek);
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
handleDateChange(nextWeek);
|
||||
}, [handleDateChange]);
|
||||
|
||||
const handleSubTaskExpand = useCallback(() => {
|
||||
if (task && task.id && projectId) {
|
||||
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count && task.sub_tasks_count > 0) {
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
} else if (task.sub_tasks_count && task.sub_tasks_count > 0) {
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
||||
} else {
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
}
|
||||
if (task && task.id && projectId) {
|
||||
if (
|
||||
task.sub_tasks &&
|
||||
task.sub_tasks.length > 0 &&
|
||||
task.sub_tasks_count &&
|
||||
task.sub_tasks_count > 0
|
||||
) {
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
} else if (task.sub_tasks_count && task.sub_tasks_count > 0) {
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
||||
} else {
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
}
|
||||
}
|
||||
}, [task, projectId, dispatch]);
|
||||
|
||||
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
const handleSubtaskButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSubTaskExpand();
|
||||
}, [handleSubTaskExpand]);
|
||||
},
|
||||
[handleSubTaskExpand]
|
||||
);
|
||||
|
||||
// Calendar rendering helpers
|
||||
const year = calendarMonth.getFullYear();
|
||||
@@ -188,304 +205,385 @@ const TaskCard: React.FC<TaskCardProps> = memo(({
|
||||
const weeks: (Date | null)[][] = [];
|
||||
let week: (Date | null)[] = Array(firstDayOfWeek).fill(null);
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
week.push(new Date(year, month, day));
|
||||
if (week.length === 7) {
|
||||
weeks.push(week);
|
||||
week = [];
|
||||
}
|
||||
week.push(new Date(year, month, day));
|
||||
if (week.length === 7) {
|
||||
weeks.push(week);
|
||||
week = [];
|
||||
}
|
||||
}
|
||||
if (week.length > 0) {
|
||||
while (week.length < 7) week.push(null);
|
||||
weeks.push(week);
|
||||
while (week.length < 7) week.push(null);
|
||||
weeks.push(week);
|
||||
}
|
||||
const [isDown, setIsDown] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="enhanced-kanban-task-card" style={{ background, color, display: 'block', position: 'relative' }} >
|
||||
{/* Progress circle at top right */}
|
||||
<div style={{ position: 'absolute', top: 6, right: 6, zIndex: 2 }}>
|
||||
<TaskProgressCircle task={task} size={20} />
|
||||
</div>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => onTaskDragStart(e, task.id!, groupId)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const offsetY = e.clientY - rect.top;
|
||||
const isDown = offsetY > rect.height / 2;
|
||||
setIsDown(isDown);
|
||||
onTaskDragOver(e, groupId, isDown ? idx + 1 : idx);
|
||||
}}
|
||||
onDrop={e => onTaskDrop(e, groupId, idx)}
|
||||
onDragEnd={onDragEnd} // <-- add this
|
||||
onClick={e => handleCardClick(e, task.id!)}
|
||||
>
|
||||
<div className="task-content">
|
||||
<div className="task_labels" style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
{task.labels?.map(label => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="task-label"
|
||||
style={{
|
||||
backgroundColor: label.color_code,
|
||||
display: 'inline-block',
|
||||
borderRadius: '2px',
|
||||
padding: '0px 4px',
|
||||
color: themeMode === 'dark' ? '#181818' : '#fff',
|
||||
fontSize: 10,
|
||||
marginRight: 4,
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="task-content" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full inline-block"
|
||||
style={{ backgroundColor: themeMode === 'dark' ? (task.priority_color_dark || task.priority_color || '#d9d9d9') : (task.priority_color || '#d9d9d9') }}
|
||||
></span>
|
||||
<div className="task-title" title={task.name} style={{ marginLeft: 8 }}>{task.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="task-assignees-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={dateButtonRef}
|
||||
className="task-due-date cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1 py-0.5 transition-colors"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#888',
|
||||
marginRight: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
onClick={handleDateClick}
|
||||
title={t('clickToChangeDate')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<div className="w-3 h-3 border border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
) : (
|
||||
selectedDate ? format(selectedDate, 'MMM d, yyyy') : t('noDueDate')
|
||||
)}
|
||||
</div>
|
||||
{/* Custom Calendar Popup */}
|
||||
{showDatePicker && dropdownPosition && (
|
||||
<Portal>
|
||||
<div
|
||||
className="w-52 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] p-1"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
}}
|
||||
ref={datePickerRef}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<button
|
||||
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setCalendarMonth(new Date(year, month - 1, 1))}
|
||||
type="button"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<span className="font-semibold text-xs text-gray-800 dark:text-gray-100">
|
||||
{calendarMonth.toLocaleString('default', { month: 'long' })} {year}
|
||||
</span>
|
||||
<button
|
||||
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setCalendarMonth(new Date(year, month + 1, 1))}
|
||||
type="button"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-0.5 text-[10px] text-center">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||||
<div key={d} className="font-medium text-gray-500 dark:text-gray-400">{d}</div>
|
||||
))}
|
||||
{weeks.map((week, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{week.map((date, j) => {
|
||||
const isSelected = date && selectedDate && date.toDateString() === selectedDate.toDateString();
|
||||
const isToday = date && date.toDateString() === today.toDateString();
|
||||
return (
|
||||
<button
|
||||
key={j}
|
||||
className={
|
||||
'w-5 h-5 rounded-full flex items-center justify-center text-[10px] ' +
|
||||
(isSelected
|
||||
? 'bg-blue-600 text-white'
|
||||
: isToday
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100')
|
||||
}
|
||||
style={{ outline: 'none' }}
|
||||
disabled={!date}
|
||||
onClick={() => date && handleDateChange(date)}
|
||||
type="button"
|
||||
>
|
||||
{date ? date.getDate() : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-0.5 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-0.5 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
onClick={handleToday}
|
||||
>
|
||||
{t('today')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-1 py-0.5 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
onClick={handleClearDate}
|
||||
>
|
||||
{t('clear')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
onClick={handleTomorrow}
|
||||
>
|
||||
{t('tomorrow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
onClick={handleNextWeek}
|
||||
>
|
||||
{t('nextWeek')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
<div className="task-assignees" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<AvatarGroup
|
||||
members={task.names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
size={24}
|
||||
/>
|
||||
<LazyAssigneeSelectorWrapper task={task} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||
{(task.sub_tasks_count ?? 0) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"ml-2 px-2 py-0.5 rounded-full flex items-center gap-1 text-xs font-medium transition-colors " +
|
||||
(task.show_sub_tasks
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: "bg-white dark:bg-[#1e1e1e] hover:bg-gray-50 dark:hover:bg-gray-700")
|
||||
}
|
||||
style={{
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
border: "none",
|
||||
outline: "none",
|
||||
}}
|
||||
onClick={handleSubtaskButtonClick}
|
||||
title={task.show_sub_tasks ? t('hideSubtasks') || 'Hide Subtasks' : t('showSubtasks') || 'Show Subtasks'}
|
||||
>
|
||||
{/* Fork/branch icon */}
|
||||
<svg style={{ color: '#888' }} className="w-2 h-2" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
|
||||
<path d="M6 3v2a2 2 0 002 2h4a2 2 0 012 2v2" strokeLinecap="round" />
|
||||
<circle cx="6" cy="3" r="2" fill="currentColor" />
|
||||
<circle cx="16" cy="9" r="2" fill="currentColor" />
|
||||
<circle cx="6" cy="17" r="2" fill="currentColor" />
|
||||
<path d="M6 5v10" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
color: '#888',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
}}>{task.sub_tasks_count ?? 0}</span>
|
||||
{/* Caret icon */}
|
||||
{task.show_sub_tasks ? (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
|
||||
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 20 20">
|
||||
<path d="M8 6l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="subtasks-container"
|
||||
<>
|
||||
<div
|
||||
className="enhanced-kanban-task-card"
|
||||
style={{ background, color, display: 'block', position: 'relative' }}
|
||||
>
|
||||
{/* Progress circle at top right */}
|
||||
<div style={{ position: 'absolute', top: 6, right: 6, zIndex: 2 }}>
|
||||
<TaskProgressCircle task={task} size={20} />
|
||||
</div>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => onTaskDragStart(e, task.id!, groupId)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const offsetY = e.clientY - rect.top;
|
||||
const isDown = offsetY > rect.height / 2;
|
||||
setIsDown(isDown);
|
||||
onTaskDragOver(e, groupId, isDown ? idx + 1 : idx);
|
||||
}}
|
||||
onDrop={e => onTaskDrop(e, groupId, idx)}
|
||||
onDragEnd={onDragEnd} // <-- add this
|
||||
onClick={e => handleCardClick(e, task.id!)}
|
||||
>
|
||||
<div className="task-content">
|
||||
<div className="task_labels" style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
{task.labels?.map(label => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="task-label"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: task.show_sub_tasks ? '500px' : '0px',
|
||||
opacity: task.show_sub_tasks ? 1 : 0,
|
||||
transform: task.show_sub_tasks ? 'translateY(0)' : 'translateY(-10px)',
|
||||
backgroundColor: label.color_code,
|
||||
display: 'inline-block',
|
||||
borderRadius: '2px',
|
||||
padding: '0px 4px',
|
||||
color: themeMode === 'dark' ? '#181818' : '#fff',
|
||||
fontSize: 10,
|
||||
marginRight: 4,
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div className="mt-2 border-t border-gray-100 dark:border-gray-700 pt-2">
|
||||
{/* Loading state */}
|
||||
{task.sub_tasks_loading && (
|
||||
<div className="h-4 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
|
||||
)}
|
||||
{/* Loaded subtasks */}
|
||||
{!task.sub_tasks_loading && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{task.sub_tasks.map(sub => (
|
||||
<li key={sub.id} onClick={e => handleCardClick(e, sub.id!)} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{sub.priority_color || sub.priority_color_dark ? (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full inline-block"
|
||||
style={{ backgroundColor: themeMode === 'dark' ? (sub.priority_color_dark || sub.priority_color || '#d9d9d9') : (sub.priority_color || '#d9d9d9') }}
|
||||
></span>
|
||||
) : null}
|
||||
<span className="flex-1 truncate text-xs text-gray-800 dark:text-gray-100" title={sub.name}>{sub.name}</span>
|
||||
<span
|
||||
className="task-due-date ml-2 text-[10px] text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{sub.end_date ? format(new Date(sub.end_date), 'MMM d, yyyy') : ''}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
{sub.names && sub.names.length > 0 && (
|
||||
<AvatarGroup
|
||||
members={sub.names}
|
||||
maxCount={2}
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<LazyAssigneeSelectorWrapper task={sub} groupId={groupId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Empty state */}
|
||||
{!task.sub_tasks_loading && (!Array.isArray(task.sub_tasks) || task.sub_tasks.length === 0) && (
|
||||
<div className="py-2 text-xs text-gray-400 dark:text-gray-500">{t('noSubtasks', 'No subtasks')}</div>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="task-content" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full inline-block"
|
||||
style={{
|
||||
backgroundColor:
|
||||
themeMode === 'dark'
|
||||
? task.priority_color_dark || task.priority_color || '#d9d9d9'
|
||||
: task.priority_color || '#d9d9d9',
|
||||
}}
|
||||
></span>
|
||||
<div className="task-title" title={task.name} style={{ marginLeft: 8 }}>
|
||||
{task.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="task-assignees-row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={dateButtonRef}
|
||||
className="task-due-date cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1 py-0.5 transition-colors"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#888',
|
||||
marginRight: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
onClick={handleDateClick}
|
||||
title={t('clickToChangeDate')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<div className="w-3 h-3 border border-gray-300 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
) : selectedDate ? (
|
||||
format(selectedDate, 'MMM d, yyyy')
|
||||
) : (
|
||||
t('noDueDate')
|
||||
)}
|
||||
</div>
|
||||
{/* Custom Calendar Popup */}
|
||||
{showDatePicker && dropdownPosition && (
|
||||
<Portal>
|
||||
<div
|
||||
className="w-52 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] p-1"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
}}
|
||||
ref={datePickerRef}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<button
|
||||
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setCalendarMonth(new Date(year, month - 1, 1))}
|
||||
type="button"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<span className="font-semibold text-xs text-gray-800 dark:text-gray-100">
|
||||
{calendarMonth.toLocaleString('default', { month: 'long' })} {year}
|
||||
</span>
|
||||
<button
|
||||
className="px-0.5 py-0.5 text-[10px] rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setCalendarMonth(new Date(year, month + 1, 1))}
|
||||
type="button"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-0.5 text-[10px] text-center">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||||
<div key={d} className="font-medium text-gray-500 dark:text-gray-400">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{weeks.map((week, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{week.map((date, j) => {
|
||||
const isSelected =
|
||||
date &&
|
||||
selectedDate &&
|
||||
date.toDateString() === selectedDate.toDateString();
|
||||
const isToday =
|
||||
date && date.toDateString() === today.toDateString();
|
||||
return (
|
||||
<button
|
||||
key={j}
|
||||
className={
|
||||
'w-5 h-5 rounded-full flex items-center justify-center text-[10px] ' +
|
||||
(isSelected
|
||||
? 'bg-blue-600 text-white'
|
||||
: isToday
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100')
|
||||
}
|
||||
style={{ outline: 'none' }}
|
||||
disabled={!date}
|
||||
onClick={() => date && handleDateChange(date)}
|
||||
type="button"
|
||||
>
|
||||
{date ? date.getDate() : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-0.5 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-0.5 py-0.5 text-[10px] bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
onClick={handleToday}
|
||||
>
|
||||
{t('today')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-1 py-0.5 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
onClick={handleClearDate}
|
||||
>
|
||||
{t('clear')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
onClick={handleTomorrow}
|
||||
>
|
||||
{t('tomorrow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 px-1 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
onClick={handleNextWeek}
|
||||
>
|
||||
{t('nextWeek')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
<div className="task-assignees" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<AvatarGroup
|
||||
members={task.names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
size={24}
|
||||
/>
|
||||
<LazyAssigneeSelectorWrapper
|
||||
task={task}
|
||||
groupId={groupId}
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
kanbanMode={true}
|
||||
/>
|
||||
{(task.sub_tasks_count ?? 0) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'ml-2 px-2 py-0.5 rounded-full flex items-center gap-1 text-xs font-medium transition-colors ' +
|
||||
(task.show_sub_tasks
|
||||
? 'bg-gray-100 dark:bg-gray-800'
|
||||
: 'bg-white dark:bg-[#1e1e1e] hover:bg-gray-50 dark:hover:bg-gray-700')
|
||||
}
|
||||
style={{
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
onClick={handleSubtaskButtonClick}
|
||||
title={
|
||||
task.show_sub_tasks
|
||||
? t('hideSubtasks') || 'Hide Subtasks'
|
||||
: t('showSubtasks') || 'Show Subtasks'
|
||||
}
|
||||
>
|
||||
{/* Fork/branch icon */}
|
||||
<svg
|
||||
style={{ color: '#888' }}
|
||||
className="w-2 h-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M6 3v2a2 2 0 002 2h4a2 2 0 012 2v2" strokeLinecap="round" />
|
||||
<circle cx="6" cy="3" r="2" fill="currentColor" />
|
||||
<circle cx="16" cy="9" r="2" fill="currentColor" />
|
||||
<circle cx="6" cy="17" r="2" fill="currentColor" />
|
||||
<path d="M6 5v10" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#888',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{task.sub_tasks_count ?? 0}
|
||||
</span>
|
||||
{/* Caret icon */}
|
||||
{task.show_sub_tasks ? (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M8 6l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
<div
|
||||
className="subtasks-container"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: task.show_sub_tasks ? '500px' : '0px',
|
||||
opacity: task.show_sub_tasks ? 1 : 0,
|
||||
transform: task.show_sub_tasks ? 'translateY(0)' : 'translateY(-10px)',
|
||||
}}
|
||||
>
|
||||
<div className="mt-2 border-t border-gray-100 dark:border-gray-700 pt-2">
|
||||
{/* Loading state */}
|
||||
{task.sub_tasks_loading && (
|
||||
<div className="h-4 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
|
||||
)}
|
||||
{/* Loaded subtasks */}
|
||||
{!task.sub_tasks_loading &&
|
||||
Array.isArray(task.sub_tasks) &&
|
||||
task.sub_tasks.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{task.sub_tasks.map(sub => (
|
||||
<li
|
||||
key={sub.id}
|
||||
onClick={e => handleCardClick(e, sub.id!)}
|
||||
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{sub.priority_color || sub.priority_color_dark ? (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full inline-block"
|
||||
style={{
|
||||
backgroundColor:
|
||||
themeMode === 'dark'
|
||||
? sub.priority_color_dark || sub.priority_color || '#d9d9d9'
|
||||
: sub.priority_color || '#d9d9d9',
|
||||
}}
|
||||
></span>
|
||||
) : null}
|
||||
<span
|
||||
className="flex-1 truncate text-xs text-gray-800 dark:text-gray-100"
|
||||
title={sub.name}
|
||||
>
|
||||
{sub.name}
|
||||
</span>
|
||||
<span className="task-due-date ml-2 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{sub.end_date ? format(new Date(sub.end_date), 'MMM d, yyyy') : ''}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
{sub.names && sub.names.length > 0 && (
|
||||
<AvatarGroup
|
||||
members={sub.names}
|
||||
maxCount={2}
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<LazyAssigneeSelectorWrapper
|
||||
task={sub}
|
||||
groupId={groupId}
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
kanbanMode={true}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Empty state */}
|
||||
{!task.sub_tasks_loading &&
|
||||
(!Array.isArray(task.sub_tasks) || task.sub_tasks.length === 0) && (
|
||||
<div className="py-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{t('noSubtasks', 'No subtasks')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
TaskCard.displayName = 'TaskCard';
|
||||
|
||||
export default TaskCard;
|
||||
export default TaskCard;
|
||||
|
||||
@@ -1,52 +1,72 @@
|
||||
import { IProjectTask } from "@/types/project/projectTasksViewModel.types";
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
// Add a simple circular progress component
|
||||
const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({ task, size = 28 }) => {
|
||||
const progress = typeof task.complete_ratio === 'number'
|
||||
? task.complete_ratio
|
||||
: (typeof task.progress === 'number' ? task.progress : 0);
|
||||
const strokeWidth = 1.5;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (progress / 100) * circumference;
|
||||
return (
|
||||
<svg width={size} height={size} style={{ display: 'block' }}>
|
||||
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={progress === 100 ? "#22c55e" : "#3b82f6"}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.3s' }}
|
||||
const TaskProgressCircle: React.FC<{ task: IProjectTask; size?: number }> = ({
|
||||
task,
|
||||
size = 28,
|
||||
}) => {
|
||||
const progress =
|
||||
typeof task.complete_ratio === 'number'
|
||||
? task.complete_ratio
|
||||
: typeof task.progress === 'number'
|
||||
? task.progress
|
||||
: 0;
|
||||
const strokeWidth = 1.5;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (progress / 100) * circumference;
|
||||
return (
|
||||
<svg width={size} height={size} style={{ display: 'block' }}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={progress === 100 ? '#22c55e' : '#3b82f6'}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.3s' }}
|
||||
/>
|
||||
{progress === 100 ? (
|
||||
// Green checkmark icon
|
||||
<g>
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="#22c55e" opacity="0.15" />
|
||||
<svg
|
||||
x={size / 2 - size * 0.22}
|
||||
y={size / 2 - size * 0.22}
|
||||
width={size * 0.44}
|
||||
height={size * 0.44}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M5 13l4 4L19 7"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="2.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{progress === 100 ? (
|
||||
// Green checkmark icon
|
||||
<g>
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="#22c55e" opacity="0.15" />
|
||||
<svg x={(size/2)-(size*0.22)} y={(size/2)-(size*0.22)} width={size*0.44} height={size*0.44} viewBox="0 0 24 24">
|
||||
<path d="M5 13l4 4L19 7" stroke="#22c55e" strokeWidth="2.2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</g>
|
||||
) : progress > 0 && (
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={size * 0.38}
|
||||
fill="#3b82f6"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{Math.round(progress)}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
</svg>
|
||||
</g>
|
||||
) : (
|
||||
progress > 0 && (
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={size * 0.38}
|
||||
fill="#3b82f6"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{Math.round(progress)}
|
||||
</text>
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgressCircle;
|
||||
export default TaskProgressCircle;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default } from './EnhancedKanbanBoardNativeDnD';
|
||||
export { default as TaskCard } from './TaskCard';
|
||||
export { default as KanbanGroup } from './KanbanGroup';
|
||||
export { default as KanbanGroup } from './KanbanGroup';
|
||||
|
||||
@@ -68,7 +68,8 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node) &&
|
||||
(!categoryDropdownRef.current || !categoryDropdownRef.current.contains(event.target as Node))
|
||||
(!categoryDropdownRef.current ||
|
||||
!categoryDropdownRef.current.contains(event.target as Node))
|
||||
) {
|
||||
setIsAdding(false);
|
||||
setSectionName('');
|
||||
@@ -109,7 +110,11 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
setIsAdding(true);
|
||||
setSectionName('');
|
||||
// Default to first category if available
|
||||
if (statusCategories && statusCategories.length > 0 && typeof statusCategories[0].id === 'string') {
|
||||
if (
|
||||
statusCategories &&
|
||||
statusCategories.length > 0 &&
|
||||
typeof statusCategories[0].id === 'string'
|
||||
) {
|
||||
setSelectedCategoryId(statusCategories[0].id);
|
||||
} else {
|
||||
setSelectedCategoryId('');
|
||||
@@ -217,10 +222,15 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
style={{ minWidth: 80 }}
|
||||
onClick={() => setShowCategoryDropdown(v => !v)}
|
||||
>
|
||||
<span className={themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'} style={{ fontSize: 13 }}>
|
||||
<span
|
||||
className={themeMode === 'dark' ? 'text-gray-800' : 'text-gray-900'}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
{selectedCategory?.name || t('changeCategory')}
|
||||
</span>
|
||||
<DownOutlined style={{ fontSize: 12, color: themeMode === 'dark' ? '#555' : '#555' }} />
|
||||
<DownOutlined
|
||||
style={{ fontSize: 12, color: themeMode === 'dark' ? '#555' : '#555' }}
|
||||
/>
|
||||
</button>
|
||||
{showCategoryDropdown && (
|
||||
<div
|
||||
@@ -228,23 +238,27 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
<div className="py-1">
|
||||
{statusCategories.filter(cat => typeof cat.id === 'string').map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
onClick={() => {
|
||||
if (typeof cat.id === 'string') setSelectedCategoryId(cat.id);
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: cat.color_code }}
|
||||
></div>
|
||||
<span className={selectedCategoryId === cat.id ? 'font-bold' : ''}>{cat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{statusCategories
|
||||
.filter(cat => typeof cat.id === 'string')
|
||||
.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
onClick={() => {
|
||||
if (typeof cat.id === 'string') setSelectedCategoryId(cat.id);
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: cat.color_code }}
|
||||
></div>
|
||||
<span className={selectedCategoryId === cat.id ? 'font-bold' : ''}>
|
||||
{cat.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -263,7 +277,12 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={() => { setIsAdding(false); setSectionName(''); setSelectedCategoryId(''); setShowCategoryDropdown(false); }}
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setSectionName('');
|
||||
setSelectedCategoryId('');
|
||||
setShowCategoryDropdown(false);
|
||||
}}
|
||||
>
|
||||
{t('deleteConfirmationCancel')}
|
||||
</Button>
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
|
||||
html.light .enhanced-kanban-task-card {
|
||||
border: 1.5px solid #e1e4e8 !important; /* Asana-like light border */
|
||||
box-shadow: 0 1px 4px 0 rgba(60, 64, 67, 0.08), 0 0.5px 1.5px 0 rgba(60, 64, 67, 0.03);
|
||||
box-shadow:
|
||||
0 1px 4px 0 rgba(60, 64, 67, 0.08),
|
||||
0 0.5px 1.5px 0 rgba(60, 64, 67, 0.03);
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,14 +182,24 @@ const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
size={24}
|
||||
/>
|
||||
<LazyAssigneeSelectorWrapper task={task} groupId={sectionId} isDarkMode={themeMode === 'dark'} kanbanMode={true} />
|
||||
<LazyAssigneeSelectorWrapper
|
||||
task={task}
|
||||
groupId={sectionId}
|
||||
isDarkMode={themeMode === 'dark'}
|
||||
kanbanMode={true}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex gap={4} align="center">
|
||||
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
||||
|
||||
{/* Subtask Section - only show if count > 1 */}
|
||||
{task.sub_tasks_count != null && Number(task.sub_tasks_count) > 1 && (
|
||||
<Tooltip title={t(`indicators.tooltips.subtasks${Number(task.sub_tasks_count) === 1 ? '' : '_plural'}`, { count: Number(task.sub_tasks_count) })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.subtasks${Number(task.sub_tasks_count) === 1 ? '' : '_plural'}`,
|
||||
{ count: Number(task.sub_tasks_count) }
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
onClick={handleSubtaskButtonClick}
|
||||
size="small"
|
||||
|
||||
@@ -198,14 +198,24 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
||||
</span>
|
||||
)}
|
||||
{task.comments_count && task.comments_count > 1 && (
|
||||
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.comments_count }
|
||||
)}
|
||||
>
|
||||
<span className="kanban-task-indicator">
|
||||
<MessageOutlined /> {task.comments_count}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.attachments_count && task.attachments_count > 1 && (
|
||||
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.attachments_count }
|
||||
)}
|
||||
>
|
||||
<span className="kanban-task-indicator">
|
||||
<PaperClipOutlined /> {task.attachments_count}
|
||||
</span>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { RootState } from '@/app/store';
|
||||
import { fetchTaskGroups, reorderTasks } from '@/features/tasks/tasks.slice';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { AppDispatch } from '@/app/store';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import KanbanGroup from './kanbanGroup';
|
||||
import KanbanTaskCard from './kanbanTaskCard';
|
||||
|
||||
@@ -124,19 +124,19 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
||||
e.stopPropagation();
|
||||
console.log('Opening project drawer from project group for project:', projectId);
|
||||
trackMixpanelEvent(evt_projects_settings_click);
|
||||
|
||||
|
||||
// Set project ID first
|
||||
dispatch(setProjectId(projectId));
|
||||
|
||||
|
||||
// Then fetch project data
|
||||
dispatch(fetchProjectData(projectId))
|
||||
.unwrap()
|
||||
.then((projectData) => {
|
||||
.then(projectData => {
|
||||
console.log('Project data fetched successfully from project group:', projectData);
|
||||
// Open drawer after data is fetched
|
||||
dispatch(toggleProjectDrawer());
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('Failed to fetch project data from project group:', error);
|
||||
// Still open drawer even if fetch fails, so user can see error state
|
||||
dispatch(toggleProjectDrawer());
|
||||
|
||||
@@ -47,19 +47,19 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
if (record.id) {
|
||||
console.log('Opening project drawer for project:', record.id);
|
||||
trackMixpanelEvent(evt_projects_settings_click);
|
||||
|
||||
|
||||
// Set project ID first
|
||||
dispatch(setProjectId(record.id));
|
||||
|
||||
|
||||
// Then fetch project data
|
||||
dispatch(fetchProjectData(record.id))
|
||||
.unwrap()
|
||||
.then((projectData) => {
|
||||
.then(projectData => {
|
||||
console.log('Project data fetched successfully:', projectData);
|
||||
// Open drawer after data is fetched
|
||||
dispatch(toggleProjectDrawer());
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('Failed to fetch project data:', error);
|
||||
// Still open drawer even if fetch fails, so user can see error state
|
||||
dispatch(toggleProjectDrawer());
|
||||
|
||||
@@ -19,11 +19,7 @@ const CreateStatusButton = () => {
|
||||
className="borderless-icon-btn"
|
||||
style={{ backgroundColor: colors.transparent, boxShadow: 'none' }}
|
||||
onClick={() => dispatch(toggleDrawer())}
|
||||
icon={
|
||||
<SettingOutlined
|
||||
style={{ color: themeMode === 'dark' ? colors.white : 'black' }}
|
||||
/>
|
||||
}
|
||||
icon={<SettingOutlined style={{ color: themeMode === 'dark' ? colors.white : 'black' }} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,11 @@ import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
|
||||
import './create-status-drawer.css';
|
||||
|
||||
import { createStatus, fetchStatusesCategories, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import {
|
||||
createStatus,
|
||||
fetchStatusesCategories,
|
||||
fetchStatuses,
|
||||
} from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { ITaskStatusCategory } from '@/types/status.types';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
|
||||
@@ -12,10 +12,7 @@ import { deleteStatusToggleDrawer } from '@/features/projects/status/DeleteStatu
|
||||
import { Drawer, Alert, Card, Select, Button, Typography, Badge } from '@/shared/antd-imports';
|
||||
import { DownOutlined } from '@/shared/antd-imports';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
deleteSection,
|
||||
IGroupBy,
|
||||
} from '@features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { deleteSection, IGroupBy } from '@features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Checkbox, Button, Flex, Typography, Space, Divider, message } from '@/shared/antd-imports';
|
||||
import {
|
||||
Modal,
|
||||
Checkbox,
|
||||
Button,
|
||||
Flex,
|
||||
Typography,
|
||||
Space,
|
||||
Divider,
|
||||
message,
|
||||
} from '@/shared/antd-imports';
|
||||
import { SettingOutlined, UpOutlined, DownOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
List,
|
||||
Space,
|
||||
Typography,
|
||||
InputRef
|
||||
InputRef,
|
||||
} from '@/shared/antd-imports';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@/shared/antd-imports';
|
||||
import {
|
||||
CaretDownFilled,
|
||||
SortAscendingOutlined,
|
||||
SortDescendingOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
|
||||
import Badge from 'antd/es/badge';
|
||||
import Button from 'antd/es/button';
|
||||
|
||||
@@ -25,7 +25,8 @@ const ImportRateCardsDrawer: React.FC = () => {
|
||||
const fallbackCurrency = useAppSelector(state => state.financeReducer.currency);
|
||||
const currency = (projectCurrency || fallbackCurrency || 'USD').toUpperCase();
|
||||
|
||||
const rolesRedux = useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
|
||||
const rolesRedux =
|
||||
useAppSelector(state => state.projectFinanceRateCardReducer.rateCardRoles) || [];
|
||||
|
||||
// Loading states
|
||||
const isRatecardsLoading = useAppSelector(state => state.financeReducer.isRatecardsLoading);
|
||||
|
||||
@@ -3,7 +3,15 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { IClientsViewModel } from '@/types/client.types';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { QuestionCircleOutlined } from '@/shared/antd-imports';
|
||||
import { AutoComplete, Flex, Form, FormInstance, Spin, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
AutoComplete,
|
||||
Flex,
|
||||
Form,
|
||||
FormInstance,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
if (drawerVisible && projectId && project && !projectLoading) {
|
||||
console.log('Populating form with project data:', project);
|
||||
setEditMode(true);
|
||||
|
||||
|
||||
try {
|
||||
form.setFieldsValue({
|
||||
...project,
|
||||
@@ -148,7 +148,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
use_weighted_progress: project.use_weighted_progress || false,
|
||||
use_time_progress: project.use_time_progress || false,
|
||||
});
|
||||
|
||||
|
||||
setSelectedProjectManager(project.project_manager || null);
|
||||
setLoading(false);
|
||||
console.log('Form populated successfully with project data');
|
||||
@@ -286,7 +286,7 @@ const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
|
||||
(visible: boolean) => {
|
||||
console.log('Drawer visibility changed:', visible, 'Project ID:', projectId);
|
||||
setDrawerVisible(visible);
|
||||
|
||||
|
||||
if (!visible) {
|
||||
resetForm();
|
||||
} else if (visible && !projectId) {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useState, useMemo } 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';
|
||||
|
||||
@@ -65,12 +65,10 @@ const Billable: React.FC = () => {
|
||||
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'),
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
CaretDownFilled,
|
||||
FilterOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckboxChangeEvent
|
||||
CheckboxChangeEvent,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -60,12 +60,10 @@ const Categories: React.FC = () => {
|
||||
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'),
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -55,12 +55,10 @@ const Members: React.FC = () => {
|
||||
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'),
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -54,12 +54,10 @@ const Projects: React.FC = () => {
|
||||
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'),
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -55,12 +55,10 @@ const Team: React.FC = () => {
|
||||
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'),
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Team from './Team';
|
||||
@@ -28,7 +27,7 @@ const TimeReportPageHeader: React.FC = () => {
|
||||
await dispatch(fetchReportingTeams());
|
||||
await dispatch(fetchReportingCategories());
|
||||
await dispatch(fetchReportingProjects());
|
||||
|
||||
|
||||
// Only fetch members and utilization data for members time sheet
|
||||
if (isMembersTimeSheet) {
|
||||
await dispatch(fetchReportingMembers());
|
||||
@@ -45,7 +44,7 @@ const TimeReportPageHeader: React.FC = () => {
|
||||
<Categories />
|
||||
<Projects />
|
||||
<Billable />
|
||||
{isMembersTimeSheet && <Members/>}
|
||||
{isMembersTimeSheet && <Members />}
|
||||
{isMembersTimeSheet && <Utilization />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -56,12 +56,10 @@ const Utilization: React.FC = () => {
|
||||
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'),
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -9,24 +9,74 @@ import {
|
||||
ArrowDownOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IRPTTimeTotals } from '@/types/reporting/reporting.types';
|
||||
import { useReportingUtilization } from '@/hooks/useUtilizationCalculation';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface TotalTimeUtilizationProps {
|
||||
totals: IRPTTimeTotals;
|
||||
dateRange?: string[];
|
||||
}
|
||||
|
||||
const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) => {
|
||||
const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals, dateRange }) => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDark = themeMode === 'dark';
|
||||
const [holidayInfo, setHolidayInfo] = useState<{ count: number; adjustedHours: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Get current date range or default to this month
|
||||
const currentDateRange = useMemo(() => {
|
||||
if (dateRange && dateRange.length >= 2) {
|
||||
return {
|
||||
from: dayjs(dateRange[0]).format('YYYY-MM-DD'),
|
||||
to: dayjs(dateRange[1]).format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
to: dayjs().endOf('month').format('YYYY-MM-DD'),
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
// Temporarily disable holiday integration to prevent API spam
|
||||
// TODO: Re-enable once backend endpoints are properly implemented
|
||||
const holidayIntegrationEnabled = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!holidayIntegrationEnabled) {
|
||||
// For now, just show a placeholder holiday count
|
||||
setHolidayInfo({
|
||||
count: 0,
|
||||
adjustedHours: parseFloat(totals.total_estimated_hours || '0'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Holiday integration code will be re-enabled once backend is ready
|
||||
// ... (previous holiday calculation code)
|
||||
}, [
|
||||
currentDateRange.from,
|
||||
currentDateRange.to,
|
||||
totals.total_estimated_hours,
|
||||
holidayIntegrationEnabled,
|
||||
]);
|
||||
|
||||
const utilizationData = useMemo(() => {
|
||||
const timeLogged = parseFloat(totals.total_time_logs || '0');
|
||||
const estimatedHours = parseFloat(totals.total_estimated_hours || '0');
|
||||
const utilizationPercent = parseFloat(totals.total_utilization || '0');
|
||||
let estimatedHours = parseFloat(totals.total_estimated_hours || '0');
|
||||
|
||||
// Use holiday-adjusted hours if available
|
||||
if (holidayInfo?.adjustedHours && holidayInfo.adjustedHours > 0) {
|
||||
estimatedHours = holidayInfo.adjustedHours;
|
||||
}
|
||||
|
||||
// Recalculate utilization with holiday adjustment
|
||||
const utilizationPercent = estimatedHours > 0 ? (timeLogged / estimatedHours) * 100 : 0;
|
||||
|
||||
// Determine utilization status and color
|
||||
let status: 'under' | 'optimal' | 'over' = 'optimal';
|
||||
@@ -49,13 +99,13 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
return {
|
||||
timeLogged,
|
||||
estimatedHours,
|
||||
utilizationPercent,
|
||||
utilizationPercent: Math.round(utilizationPercent * 100) / 100,
|
||||
status,
|
||||
statusColor,
|
||||
statusIcon,
|
||||
statusText,
|
||||
};
|
||||
}, [totals, t]);
|
||||
}, [totals, t, holidayInfo]);
|
||||
|
||||
const getThemeColors = useMemo(
|
||||
() => ({
|
||||
@@ -201,7 +251,7 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{totals.total_estimated_hours}h
|
||||
{utilizationData.estimatedHours.toFixed(1)}h
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -210,7 +260,9 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{t('basedOnWorkingSchedule')}
|
||||
{holidayInfo?.count
|
||||
? `${t('basedOnWorkingSchedule')} (${holidayInfo.count} ${t('holidaysExcluded')})`
|
||||
: t('basedOnWorkingSchedule')}
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
@@ -281,7 +333,7 @@ const TotalTimeUtilization: React.FC<TotalTimeUtilizationProps> = ({ totals }) =
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{totals.total_utilization}%
|
||||
{utilizationData.utilizationPercent}%
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.min(utilizationData.utilizationPercent, 150)} // Cap at 150% for display
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { DownOutlined } from '@/shared/antd-imports';
|
||||
import { Button, Card, DatePicker, Divider, Dropdown, Flex, List, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Flex,
|
||||
List,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Badge, Button, Space, Tooltip, message } from '@/shared/antd-imports';
|
||||
import { WifiOutlined, DisconnectOutlined, ReloadOutlined, DeleteOutlined } from '@/shared/antd-imports';
|
||||
import {
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
|
||||
|
||||
interface ServiceWorkerStatusProps {
|
||||
@@ -11,9 +16,9 @@ interface ServiceWorkerStatusProps {
|
||||
showControls?: boolean; // Show cache management controls
|
||||
}
|
||||
|
||||
const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
minimal = false,
|
||||
showControls = false
|
||||
const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
minimal = false,
|
||||
showControls = false,
|
||||
}) => {
|
||||
const { isOffline, swManager, clearCache, forceUpdate, getVersion } = useServiceWorker();
|
||||
const [swVersion, setSwVersion] = React.useState<string>('');
|
||||
@@ -25,18 +30,20 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
if (getVersion) {
|
||||
const versionPromise = getVersion();
|
||||
if (versionPromise) {
|
||||
versionPromise.then(version => {
|
||||
setSwVersion(version);
|
||||
}).catch(() => {
|
||||
// Ignore errors when getting version
|
||||
});
|
||||
versionPromise
|
||||
.then(version => {
|
||||
setSwVersion(version);
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore errors when getting version
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [getVersion]);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (!clearCache) return;
|
||||
|
||||
|
||||
setIsClearing(true);
|
||||
try {
|
||||
const success = await clearCache();
|
||||
@@ -54,7 +61,7 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
|
||||
const handleForceUpdate = async () => {
|
||||
if (!forceUpdate) return;
|
||||
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await forceUpdate();
|
||||
@@ -69,10 +76,7 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
if (minimal) {
|
||||
return (
|
||||
<Tooltip title={isOffline ? 'You are offline' : 'You are online'}>
|
||||
<Badge
|
||||
status={isOffline ? 'error' : 'success'}
|
||||
text={isOffline ? 'Offline' : 'Online'}
|
||||
/>
|
||||
<Badge status={isOffline ? 'error' : 'success'} text={isOffline ? 'Offline' : 'Online'} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -87,23 +91,15 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
) : (
|
||||
<WifiOutlined style={{ color: '#52c41a' }} />
|
||||
)}
|
||||
<span style={{ fontSize: '14px' }}>
|
||||
{isOffline ? 'Offline Mode' : 'Online'}
|
||||
</span>
|
||||
{swVersion && (
|
||||
<span style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
v{swVersion}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '14px' }}>{isOffline ? 'Offline Mode' : 'Online'}</span>
|
||||
{swVersion && <span style={{ fontSize: '12px', color: '#8c8c8c' }}>v{swVersion}</span>}
|
||||
</div>
|
||||
|
||||
{/* Information */}
|
||||
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||
{isOffline ? (
|
||||
'App is running from cache. Changes will sync when online.'
|
||||
) : (
|
||||
'App is cached for offline use. Ready to work anywhere!'
|
||||
)}
|
||||
{isOffline
|
||||
? 'App is running from cache. Changes will sync when online.'
|
||||
: 'App is cached for offline use. Ready to work anywhere!'}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
@@ -119,7 +115,7 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
Clear Cache
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip title="Check for updates and reload">
|
||||
<Button
|
||||
size="small"
|
||||
@@ -137,4 +133,4 @@ const ServiceWorkerStatus: React.FC<ServiceWorkerStatusProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceWorkerStatus;
|
||||
export default ServiceWorkerStatus;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Timeline, Typography, Flex, ConfigProvider, Tag, Tooltip, Skeleton } from '@/shared/antd-imports';
|
||||
import {
|
||||
Timeline,
|
||||
Typography,
|
||||
Flex,
|
||||
ConfigProvider,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Skeleton,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRightOutlined } from '@/shared/antd-imports';
|
||||
@@ -73,7 +81,11 @@ const TaskDrawerActivityLog = () => {
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
|
||||
<Tag color={'default'}>{activity.log_type === 'create' ? t('taskActivityLogTab.add') : t('taskActivityLogTab.remove')}</Tag>
|
||||
<Tag color={'default'}>
|
||||
{activity.log_type === 'create'
|
||||
? t('taskActivityLogTab.add')
|
||||
: t('taskActivityLogTab.remove')}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -156,20 +168,28 @@ const TaskDrawerActivityLog = () => {
|
||||
case IActivityLogAttributeTypes.WEIGHT:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color="purple">{t('taskActivityLogTab.weight')}: {activity.previous || '100'}</Tag>
|
||||
<Tag color="purple">
|
||||
{t('taskActivityLogTab.weight')}: {activity.previous || '100'}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
|
||||
<Tag color="purple">{t('taskActivityLogTab.weight')}: {activity.current || '100'}</Tag>
|
||||
<Tag color="purple">
|
||||
{t('taskActivityLogTab.weight')}: {activity.current || '100'}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color={'default'}>{truncateText(activity.previous) || t('taskActivityLogTab.none')}</Tag>
|
||||
<Tag color={'default'}>
|
||||
{truncateText(activity.previous) || t('taskActivityLogTab.none')}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
|
||||
<Tag color={'default'}>{truncateText(activity.current) || t('taskActivityLogTab.none')}</Tag>
|
||||
<Tag color={'default'}>
|
||||
{truncateText(activity.current) || t('taskActivityLogTab.none')}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { ITaskAttachmentViewModel } from '@/types/tasks/task-attachment-view-model';
|
||||
import { Button, Modal, Spin, Tooltip, Typography, Popconfirm, message } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from '@/shared/antd-imports';
|
||||
import {
|
||||
EyeOutlined,
|
||||
DownloadOutlined,
|
||||
|
||||
@@ -121,14 +121,16 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
||||
});
|
||||
|
||||
setComments(sortedComments);
|
||||
|
||||
|
||||
// Update Redux state with the current comment count
|
||||
dispatch(updateTaskCounts({
|
||||
taskId,
|
||||
counts: {
|
||||
comments_count: sortedComments.length
|
||||
}
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId,
|
||||
counts: {
|
||||
comments_count: sortedComments.length,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -212,7 +214,7 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
||||
if (res.done) {
|
||||
// Refresh comments to get updated list
|
||||
await getComments(false);
|
||||
|
||||
|
||||
// The comment count will be updated by getComments function
|
||||
// No need to dispatch here as getComments already handles it
|
||||
}
|
||||
@@ -244,9 +246,11 @@ const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
|
||||
await getComments(false);
|
||||
|
||||
// Dispatch event to notify that an attachment was deleted
|
||||
document.dispatchEvent(new CustomEvent('task-comment-update', {
|
||||
detail: { taskId }
|
||||
}));
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('task-comment-update', {
|
||||
detail: { taskId },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error deleting attachment', e);
|
||||
|
||||
@@ -58,9 +58,11 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
|
||||
onUpdated(commentData);
|
||||
|
||||
// Dispatch event to notify that a comment was updated
|
||||
document.dispatchEvent(new CustomEvent('task-comment-update', {
|
||||
detail: { taskId: commentData.task_id }
|
||||
}));
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('task-comment-update', {
|
||||
detail: { taskId: commentData.task_id },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error updating comment', e);
|
||||
|
||||
@@ -63,14 +63,16 @@ const DependenciesTable = ({
|
||||
setIsDependencyInputShow(false);
|
||||
setTaskList([]);
|
||||
setSearchTerm('');
|
||||
|
||||
|
||||
// Update Redux state with dependency status
|
||||
dispatch(updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_dependencies: true
|
||||
}
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_dependencies: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding dependency:', error);
|
||||
@@ -100,16 +102,18 @@ const DependenciesTable = ({
|
||||
const res = await taskDependenciesApiService.deleteTaskDependency(dependencyId);
|
||||
if (res.done) {
|
||||
refreshTaskDependencies();
|
||||
|
||||
|
||||
// Update Redux state with dependency status
|
||||
// Check if there are any remaining dependencies
|
||||
const remainingDependencies = taskDependencies.filter(dep => dep.id !== dependencyId);
|
||||
dispatch(updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_dependencies: remainingDependencies.length > 0
|
||||
}
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_dependencies: remainingDependencies.length > 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting dependency:', error);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
|
||||
// Lazy load TinyMCE editor to reduce initial bundle size
|
||||
const LazyTinyMCEEditor = lazy(() =>
|
||||
const LazyTinyMCEEditor = lazy(() =>
|
||||
import('@tinymce/tinymce-react').then(module => ({ default: module.Editor }))
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
// Load TinyMCE script only when editor is opened
|
||||
const loadTinyMCE = async () => {
|
||||
if (isTinyMCELoaded) return;
|
||||
|
||||
|
||||
setIsEditorLoading(true);
|
||||
try {
|
||||
// Load TinyMCE script dynamically
|
||||
@@ -51,7 +51,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = '/tinymce/tinymce.min.js';
|
||||
script.async = true;
|
||||
@@ -59,7 +59,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
script.onerror = () => reject(new Error('Failed to load TinyMCE'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
|
||||
setIsTinyMCELoaded(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load TinyMCE:', error);
|
||||
@@ -87,7 +87,9 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
const isClickedInsideWrapper = wrapper && wrapper.contains(target);
|
||||
const isClickedInsideEditor = document.querySelector('.tox-tinymce')?.contains(target);
|
||||
const isClickedInsideToolbarPopup = document
|
||||
.querySelector('.tox-menu, .tox-pop, .tox-collection, .tox-dialog, .tox-dialog-wrap, .tox-silver-sink')
|
||||
.querySelector(
|
||||
'.tox-menu, .tox-pop, .tox-collection, .tox-dialog, .tox-dialog-wrap, .tox-silver-sink'
|
||||
)
|
||||
?.contains(target);
|
||||
|
||||
if (
|
||||
@@ -133,7 +135,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
|
||||
const handleContentClick = (event: React.MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
|
||||
// Check if clicked element is a link
|
||||
if (target.tagName === 'A' || target.closest('a')) {
|
||||
event.preventDefault(); // Prevent default link behavior
|
||||
@@ -148,7 +150,7 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// If not a link, open the editor
|
||||
handleOpenEditor();
|
||||
};
|
||||
@@ -269,8 +271,8 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
|
||||
? '#2a2a2a'
|
||||
: '#fafafa'
|
||||
: themeMode === 'dark'
|
||||
? '#1e1e1e'
|
||||
: '#ffffff',
|
||||
? '#1e1e1e'
|
||||
: '#ffffff',
|
||||
color: themeMode === 'dark' ? '#ffffff' : '#000000',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
|
||||
@@ -102,12 +102,14 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
);
|
||||
|
||||
// Update Redux state with recurring task status
|
||||
dispatch(updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
schedule_id: schedule.id as string || null
|
||||
}
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
schedule_id: (schedule.id as string) || null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
setRecurring(checked);
|
||||
if (!checked) setShowConfig(false);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Button, Flex, Form, Mentions, Space, Tooltip, Typography, message } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Form,
|
||||
Mentions,
|
||||
Space,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@/shared/antd-imports';
|
||||
@@ -6,9 +15,7 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import {
|
||||
IMentionMemberSelectOption,
|
||||
} from '@/types/project/projectComments.types';
|
||||
import { IMentionMemberSelectOption } from '@/types/project/projectComments.types';
|
||||
import { ITaskCommentsCreateRequest } from '@/types/tasks/task-comments.types';
|
||||
import { ITaskAttachment } from '@/types/tasks/task-attachment-view-model';
|
||||
import logger from '@/utils/errorLogger';
|
||||
@@ -71,7 +78,7 @@ const InfoTabFooter = () => {
|
||||
const createdFromNow = useMemo(() => {
|
||||
const createdAt = taskFormViewModel?.task?.created_at;
|
||||
if (!createdAt) return 'N/A';
|
||||
|
||||
|
||||
try {
|
||||
return fromNow(createdAt);
|
||||
} catch (error) {
|
||||
@@ -79,11 +86,11 @@ const InfoTabFooter = () => {
|
||||
return 'N/A';
|
||||
}
|
||||
}, [taskFormViewModel?.task?.created_at]);
|
||||
|
||||
|
||||
const updatedFromNow = useMemo(() => {
|
||||
const updatedAt = taskFormViewModel?.task?.updated_at;
|
||||
if (!updatedAt) return 'N/A';
|
||||
|
||||
|
||||
try {
|
||||
return fromNow(updatedAt);
|
||||
} catch (error) {
|
||||
@@ -183,12 +190,14 @@ const InfoTabFooter = () => {
|
||||
setIsCommentBoxExpand(false);
|
||||
setCommentValue('');
|
||||
setSelectedMembers([]);
|
||||
|
||||
|
||||
// Dispatch event to notify that a comment was created
|
||||
// This will trigger the task comments component to refresh and update Redux
|
||||
document.dispatchEvent(new CustomEvent('task-comment-create', {
|
||||
detail: { taskId: selectedTaskId }
|
||||
}));
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('task-comment-create', {
|
||||
detail: { taskId: selectedTaskId },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create comment:', error);
|
||||
@@ -335,7 +344,7 @@ const InfoTabFooter = () => {
|
||||
{selectedFiles.length > 0 && (
|
||||
<Flex vertical gap={8} style={{ marginTop: 12 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('taskInfoTab.comments.selectedFiles', { count: MAXIMUM_FILE_COUNT })}
|
||||
{t('taskInfoTab.comments.selectedFiles', { count: MAXIMUM_FILE_COUNT })}
|
||||
</Typography.Title>
|
||||
<Flex
|
||||
vertical
|
||||
@@ -478,30 +487,18 @@ const InfoTabFooter = () => {
|
||||
)}
|
||||
|
||||
<Flex align="center" justify="space-between" style={{ width: '100%', marginTop: 8 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
createdFromNow !== 'N/A'
|
||||
? `Created ${createdFromNow}`
|
||||
: 'N/A'
|
||||
}
|
||||
>
|
||||
<Tooltip title={createdFromNow !== 'N/A' ? `Created ${createdFromNow}` : 'N/A'}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('taskInfoTab.comments.createdBy', {
|
||||
{t('taskInfoTab.comments.createdBy', {
|
||||
time: createdFromNow,
|
||||
user: taskFormViewModel?.task?.reporter || ''
|
||||
user: taskFormViewModel?.task?.reporter || '',
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
updatedFromNow !== 'N/A'
|
||||
? `Updated ${updatedFromNow}`
|
||||
: 'N/A'
|
||||
}
|
||||
>
|
||||
<Tooltip title={updatedFromNow !== 'N/A' ? `Updated ${updatedFromNow}` : 'N/A'}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('taskInfoTab.comments.updatedTime', {
|
||||
time: updatedFromNow
|
||||
{t('taskInfoTab.comments.updatedTime', {
|
||||
time: updatedFromNow,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
@@ -101,14 +101,16 @@ const NotifyMemberSelector = ({ task, t }: NotifyMemberSelectorProps) => {
|
||||
socket?.emit(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), body);
|
||||
socket?.once(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), (data: InlineMember[]) => {
|
||||
dispatch(setTaskSubscribers(data));
|
||||
|
||||
|
||||
// Update Redux state with subscriber status
|
||||
dispatch(updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_subscribers: data && data.length > 0
|
||||
}
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: task.id,
|
||||
counts: {
|
||||
has_subscribers: data && data.length > 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error notifying member:', error);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Button, Flex, Input, Popconfirm, Progress, Table, Tag, Tooltip } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Progress,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { DeleteOutlined, EditOutlined, ExclamationCircleFilled } from '@/shared/antd-imports';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
@@ -126,7 +135,7 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
|
||||
mode: 'delete',
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
refreshSubTasks();
|
||||
} catch (error) {
|
||||
logger.error('Error deleting subtask:', error);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Button, Collapse, CollapseProps, Flex, Skeleton, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
CollapseProps,
|
||||
Flex,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@/shared/antd-imports';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { ReloadOutlined } from '@/shared/antd-imports';
|
||||
import DescriptionEditor from './description-editor';
|
||||
@@ -150,7 +158,7 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
label: <Typography.Text strong>{t('taskInfoTab.dependencies.title')}</Typography.Text>,
|
||||
children: (
|
||||
<DependenciesTable
|
||||
task={(taskFormViewModel?.task as ITaskViewModel) || {} as ITaskViewModel}
|
||||
task={(taskFormViewModel?.task as ITaskViewModel) || ({} as ITaskViewModel)}
|
||||
t={t}
|
||||
taskDependencies={taskDependencies}
|
||||
loadingTaskDependencies={loadingTaskDependencies}
|
||||
@@ -216,14 +224,16 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
const res = await taskDependenciesApiService.getTaskDependencies(selectedTaskId);
|
||||
if (res.done) {
|
||||
setTaskDependencies(res.body);
|
||||
|
||||
|
||||
// Update Redux state with the current dependency status
|
||||
dispatch(updateTaskCounts({
|
||||
taskId: selectedTaskId,
|
||||
counts: {
|
||||
has_dependencies: res.body.length > 0
|
||||
}
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: selectedTaskId,
|
||||
counts: {
|
||||
has_dependencies: res.body.length > 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task dependencies:', error);
|
||||
@@ -239,14 +249,16 @@ const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
const res = await taskAttachmentsApiService.getTaskAttachments(selectedTaskId);
|
||||
if (res.done) {
|
||||
setTaskAttachments(res.body);
|
||||
|
||||
|
||||
// Update Redux state with the current attachment count
|
||||
dispatch(updateTaskCounts({
|
||||
taskId: selectedTaskId,
|
||||
counts: {
|
||||
attachments_count: res.body.length
|
||||
}
|
||||
}));
|
||||
dispatch(
|
||||
updateTaskCounts({
|
||||
taskId: selectedTaskId,
|
||||
counts: {
|
||||
attachments_count: res.body.length,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task attachments:', error);
|
||||
|
||||
@@ -221,32 +221,40 @@ const TimeLogForm = ({
|
||||
<Flex gap={8} wrap="wrap" style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="date"
|
||||
label={t('taskTimeLogTab.timeLogForm.date')}
|
||||
rules={[{ required: true, message: t('taskTimeLogTab.timeLogForm.selectDateError') }]}
|
||||
label={t('taskTimeLogTab.timeLogForm.date')}
|
||||
rules={[{ required: true, message: t('taskTimeLogTab.timeLogForm.selectDateError') }]}
|
||||
>
|
||||
<DatePicker disabledDate={current => current && current.toDate() > new Date()} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
label={t('taskTimeLogTab.timeLogForm.startTime')}
|
||||
rules={[{ required: true, message: t('taskTimeLogTab.timeLogForm.selectStartTimeError') }]}
|
||||
label={t('taskTimeLogTab.timeLogForm.startTime')}
|
||||
rules={[
|
||||
{ required: true, message: t('taskTimeLogTab.timeLogForm.selectStartTimeError') },
|
||||
]}
|
||||
>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label={t('taskTimeLogTab.timeLogForm.endTime')}
|
||||
rules={[{ required: true, message: t('taskTimeLogTab.timeLogForm.selectEndTimeError') }]}
|
||||
label={t('taskTimeLogTab.timeLogForm.endTime')}
|
||||
rules={[
|
||||
{ required: true, message: t('taskTimeLogTab.timeLogForm.selectEndTimeError') },
|
||||
]}
|
||||
>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label={t('taskTimeLogTab.timeLogForm.workDescription')} style={{ marginBlockEnd: 12 }}>
|
||||
<Input.TextArea placeholder={t('taskTimeLogTab.timeLogForm.descriptionPlaceholder')} />
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('taskTimeLogTab.timeLogForm.workDescription')}
|
||||
style={{ marginBlockEnd: 12 }}
|
||||
>
|
||||
<Input.TextArea placeholder={t('taskTimeLogTab.timeLogForm.descriptionPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBlockEnd: 0 }}>
|
||||
@@ -258,7 +266,9 @@ const TimeLogForm = ({
|
||||
disabled={!isFormValid()}
|
||||
htmlType="submit"
|
||||
>
|
||||
{mode === 'edit' ? t('taskTimeLogTab.timeLogForm.updateTime') : t('taskTimeLogTab.timeLogForm.logTime')}
|
||||
{mode === 'edit'
|
||||
? t('taskTimeLogTab.timeLogForm.updateTime')
|
||||
: t('taskTimeLogTab.timeLogForm.logTime')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
@@ -29,6 +29,6 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .task-name-display:hover {
|
||||
[data-theme="dark"] .task-name-display:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import { deleteTask } from '@/features/tasks/tasks.slice';
|
||||
import { deleteTask as deleteTaskFromManagement } from '@/features/task-management/task-management.slice';
|
||||
import { deselectTask } from '@/features/task-management/selection.slice';
|
||||
import { deleteBoardTask } from '@/features/board/board-slice';
|
||||
import { deleteTask as deleteKanbanTask, updateEnhancedKanbanSubtask } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import {
|
||||
deleteTask as deleteKanbanTask,
|
||||
updateEnhancedKanbanSubtask,
|
||||
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import TaskHierarchyBreadcrumb from '../task-hierarchy-breadcrumb/task-hierarchy-breadcrumb';
|
||||
@@ -46,7 +49,8 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
// Check if current task is a sub-task
|
||||
const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
const isSubTask =
|
||||
taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
|
||||
useEffect(() => {
|
||||
setTaskName(taskFormViewModel?.task?.name ?? '');
|
||||
@@ -75,11 +79,17 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
dispatch(deleteTask({ taskId: selectedTaskId }));
|
||||
dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId }));
|
||||
if (taskFormViewModel?.task?.is_sub_task) {
|
||||
dispatch(updateEnhancedKanbanSubtask({
|
||||
sectionId: '',
|
||||
subtask: { id: selectedTaskId, parent_task_id: taskFormViewModel?.task?.parent_task_id || '', manual_progress: false },
|
||||
mode: 'delete',
|
||||
}));
|
||||
dispatch(
|
||||
updateEnhancedKanbanSubtask({
|
||||
sectionId: '',
|
||||
subtask: {
|
||||
id: selectedTaskId,
|
||||
parent_task_id: taskFormViewModel?.task?.parent_task_id || '',
|
||||
manual_progress: false,
|
||||
},
|
||||
mode: 'delete',
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(deleteKanbanTask(selectedTaskId)); // <-- Add this line
|
||||
}
|
||||
@@ -144,7 +154,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
<div>
|
||||
{/* Show breadcrumb for sub-tasks */}
|
||||
{isSubTask && <TaskHierarchyBreadcrumb t={t} />}
|
||||
|
||||
|
||||
<Flex gap={8} align="center" style={{ marginBlockEnd: 2 }}>
|
||||
<Flex style={{ position: 'relative', width: '100%', alignItems: 'center' }}>
|
||||
{isEditing ? (
|
||||
@@ -166,10 +176,7 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title={shouldShowTooltip ? displayTaskName : ''} trigger="hover">
|
||||
<p
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="task-name-display"
|
||||
>
|
||||
<p onClick={() => setIsEditing(true)} className="task-name-display">
|
||||
{truncatedTaskName}
|
||||
</p>
|
||||
</Tooltip>
|
||||
@@ -182,7 +189,10 @@ const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
teamId={currentSession?.team_id ?? ''}
|
||||
/>
|
||||
|
||||
<Dropdown overlayClassName={'delete-task-dropdown'} menu={{ items: deletTaskDropdownItems }}>
|
||||
<Dropdown
|
||||
overlayClassName={'delete-task-dropdown'}
|
||||
menu={{ items: deletTaskDropdownItems }}
|
||||
>
|
||||
<Button type="text" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
|
||||
@@ -60,10 +60,12 @@ const TaskDrawer = () => {
|
||||
if (taskFormViewModel?.task?.parent_task_id && projectId) {
|
||||
// Navigate to parent task
|
||||
dispatch(setSelectedTaskId(taskFormViewModel.task.parent_task_id));
|
||||
dispatch(fetchTask({
|
||||
taskId: taskFormViewModel.task.parent_task_id,
|
||||
projectId
|
||||
}));
|
||||
dispatch(
|
||||
fetchTask({
|
||||
taskId: taskFormViewModel.task.parent_task_id,
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,7 +219,8 @@ const TaskDrawer = () => {
|
||||
};
|
||||
|
||||
// Check if current task is a sub-task
|
||||
const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
const isSubTask =
|
||||
taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id;
|
||||
|
||||
// Custom close icon based on whether it's a sub-task
|
||||
const getCloseIcon = () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
[data-theme='dark'] .task-hierarchy-breadcrumb .ant-breadcrumb-separator {
|
||||
[data-theme="dark"] .task-hierarchy-breadcrumb .ant-breadcrumb-separator {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .task-hierarchy-breadcrumb .ant-btn-link:hover {
|
||||
[data-theme="dark"] .task-hierarchy-breadcrumb .ant-btn-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .task-hierarchy-breadcrumb .current-task-name {
|
||||
[data-theme="dark"] .task-hierarchy-breadcrumb .current-task-name {
|
||||
color: #ffffffd9;
|
||||
}
|
||||
|
||||
@@ -85,4 +85,4 @@
|
||||
.task-hierarchy-breadcrumb .ant-breadcrumb-item .ant-breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
// Recursively fetch the complete hierarchy path
|
||||
const fetchHierarchyPath = async (currentTaskId: string): Promise<TaskHierarchyItem[]> => {
|
||||
if (!projectId) return [];
|
||||
|
||||
|
||||
const path: TaskHierarchyItem[] = [];
|
||||
let taskId = currentTaskId;
|
||||
|
||||
|
||||
// Traverse up the hierarchy until we reach the root
|
||||
while (taskId) {
|
||||
try {
|
||||
@@ -52,9 +52,9 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
path.unshift({
|
||||
id: taskData.id,
|
||||
name: taskData.name || '',
|
||||
parent_task_id: taskData.parent_task_id || undefined
|
||||
parent_task_id: taskData.parent_task_id || undefined,
|
||||
});
|
||||
|
||||
|
||||
// Move to parent task
|
||||
taskId = taskData.parent_task_id || '';
|
||||
} else {
|
||||
@@ -65,7 +65,7 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
if (onBackClick) {
|
||||
onBackClick();
|
||||
}
|
||||
|
||||
|
||||
// Navigate to the selected task
|
||||
dispatch(setSelectedTaskId(taskId));
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
@@ -114,7 +114,7 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
...hierarchyPath.map((hierarchyTask, index) => {
|
||||
const truncatedName = truncateText(hierarchyTask.name, 25);
|
||||
const shouldShowTooltip = hierarchyTask.name.length > 25;
|
||||
|
||||
|
||||
return {
|
||||
title: (
|
||||
<Tooltip title={shouldShowTooltip ? hierarchyTask.name : ''} trigger="hover">
|
||||
@@ -149,7 +149,7 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
const currentTaskName = task?.name || t('taskHeader.currentTask', 'Current Task');
|
||||
const truncatedCurrentName = truncateText(currentTaskName, 25);
|
||||
const shouldShowCurrentTooltip = currentTaskName.length > 25;
|
||||
|
||||
|
||||
return (
|
||||
<Tooltip title={shouldShowCurrentTooltip ? currentTaskName : ''} trigger="hover">
|
||||
<Typography.Text
|
||||
@@ -186,4 +186,4 @@ const TaskHierarchyBreadcrumb: React.FC<TaskHierarchyBreadcrumbProps> = ({ t, on
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskHierarchyBreadcrumb;
|
||||
export default TaskHierarchyBreadcrumb;
|
||||
|
||||
@@ -13,18 +13,18 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
todoProgress,
|
||||
doingProgress,
|
||||
doneProgress,
|
||||
groupType
|
||||
groupType,
|
||||
}) => {
|
||||
const { t } = useTranslation('task-management');
|
||||
console.log(todoProgress, doingProgress, doneProgress);
|
||||
|
||||
|
||||
// Only show for priority and phase grouping
|
||||
if (groupType !== 'priority' && groupType !== 'phase') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = (todoProgress || 0) + (doingProgress || 0) + (doneProgress || 0);
|
||||
|
||||
|
||||
// Don't show if no progress values exist
|
||||
if (total === 0) {
|
||||
return null;
|
||||
@@ -33,9 +33,15 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
// Tooltip content with all values in rows
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
<div>{t('todo')}: {todoProgress || 0}%</div>
|
||||
<div>{t('inProgress')}: {doingProgress || 0}%</div>
|
||||
<div>{t('done')}: {doneProgress || 0}%</div>
|
||||
<div>
|
||||
{t('todo')}: {todoProgress || 0}%
|
||||
</div>
|
||||
<div>
|
||||
{t('inProgress')}: {doingProgress || 0}%
|
||||
</div>
|
||||
<div>
|
||||
{t('done')}: {doneProgress || 0}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +51,7 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap font-medium">
|
||||
{doneProgress || 0}% {t('done')}
|
||||
</span>
|
||||
|
||||
|
||||
{/* Compact progress bar */}
|
||||
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="h-full flex">
|
||||
@@ -78,28 +84,22 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Small legend dots with better spacing */}
|
||||
<div className="flex items-center gap-1">
|
||||
{todoProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
|
||||
/>
|
||||
<div className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{doingProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
|
||||
/>
|
||||
<div className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{doneProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
|
||||
/>
|
||||
<div className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -107,4 +107,4 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupProgressBar;
|
||||
export default GroupProgressBar;
|
||||
|
||||
@@ -132,13 +132,11 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 border-l-2 border-blue-200 dark:border-blue-700">
|
||||
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<div key={column.id}>
|
||||
{renderColumn(column.id, column.width)}
|
||||
</div>
|
||||
<div key={column.id}>{renderColumn(column.id, column.width)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtaskLoadingSkeleton;
|
||||
export default SubtaskLoadingSkeleton;
|
||||
|
||||
@@ -511,7 +511,11 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
|
||||
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
|
||||
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
|
||||
!(groupProgressValues.todoProgress === 0 && groupProgressValues.doingProgress === 0 && groupProgressValues.doneProgress === 0) && (
|
||||
!(
|
||||
groupProgressValues.todoProgress === 0 &&
|
||||
groupProgressValues.doingProgress === 0 &&
|
||||
groupProgressValues.doneProgress === 0
|
||||
) && (
|
||||
<div
|
||||
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
|
||||
style={{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import ImprovedTaskFilters from "../task-management/improved-task-filters";
|
||||
import TaskListV2Section from "./TaskListV2Table";
|
||||
import ImprovedTaskFilters from '../task-management/improved-task-filters';
|
||||
import TaskListV2Section from './TaskListV2Table';
|
||||
|
||||
const TaskListV2: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Task Filters */}
|
||||
|
||||
@@ -83,19 +83,22 @@ const EmptyGroupDropZone: React.FC<{
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`relative w-full transition-colors duration-200 ${
|
||||
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||
<div
|
||||
className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700"
|
||||
style={{ height: '40px' }}
|
||||
>
|
||||
{visibleColumns.map((column, index) => {
|
||||
const emptyColumnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
|
||||
// Show text in the title column
|
||||
if (column.id === 'title') {
|
||||
return (
|
||||
@@ -110,7 +113,7 @@ const EmptyGroupDropZone: React.FC<{
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`empty-${column.id}`}
|
||||
@@ -133,9 +136,9 @@ const PlaceholderDropIndicator: React.FC<{
|
||||
visibleColumns: any[];
|
||||
}> = ({ isVisible, visibleColumns }) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
|
||||
style={{ minWidth: 'max-content', height: '40px' }}
|
||||
>
|
||||
@@ -208,7 +211,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
|
||||
// State hooks
|
||||
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
|
||||
const [addTaskRows, setAddTaskRows] = useState<{[groupId: string]: string[]}>({});
|
||||
const [addTaskRows, setAddTaskRows] = useState<{ [groupId: string]: string[] }>({});
|
||||
|
||||
// Configure sensors for drag and drop
|
||||
const sensors = useSensors(
|
||||
@@ -450,17 +453,17 @@ const TaskListV2Section: React.FC = () => {
|
||||
const handleTaskAdded = useCallback((rowId: string) => {
|
||||
// Task is now added in real-time via socket, no need to refetch
|
||||
// The global socket handler will handle the real-time update
|
||||
|
||||
|
||||
// Find the group this row belongs to
|
||||
const groupId = rowId.split('-')[2]; // Extract from rowId format: add-task-{groupId}-{index}
|
||||
|
||||
|
||||
// Add a new add task row to this group
|
||||
setAddTaskRows(prev => {
|
||||
const currentRows = prev[groupId] || [];
|
||||
const newRowId = `add-task-${groupId}-${currentRows.length + 1}`;
|
||||
return {
|
||||
...prev,
|
||||
[groupId]: [...currentRows, newRowId]
|
||||
[groupId]: [...currentRows, newRowId],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
@@ -490,7 +493,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
|
||||
// Get add task rows for this group
|
||||
const groupAddRows = addTaskRows[group.id] || [];
|
||||
const addTaskItems = !isCurrentGroupCollapsed
|
||||
const addTaskItems = !isCurrentGroupCollapsed
|
||||
? [
|
||||
// Default add task row
|
||||
{
|
||||
@@ -513,7 +516,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
projectId: urlProjectId,
|
||||
rowId: rowId,
|
||||
autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row
|
||||
}))
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -542,7 +545,6 @@ const TaskListV2Section: React.FC = () => {
|
||||
return virtuosoGroups.flatMap(group => group.tasks);
|
||||
}, [virtuosoGroups]);
|
||||
|
||||
|
||||
// Render functions
|
||||
const renderGroup = useCallback(
|
||||
(groupIndex: number) => {
|
||||
@@ -564,11 +566,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
projectId={urlProjectId || ''}
|
||||
/>
|
||||
{isGroupEmpty && !isGroupCollapsed && (
|
||||
<EmptyGroupDropZone
|
||||
groupId={group.id}
|
||||
visibleColumns={visibleColumns}
|
||||
t={t}
|
||||
/>
|
||||
<EmptyGroupDropZone groupId={group.id} visibleColumns={visibleColumns} t={t} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -703,9 +701,9 @@ const TaskListV2Section: React.FC = () => {
|
||||
color: '#fbc84c69',
|
||||
actualCount: 0,
|
||||
count: 1, // For the add task row
|
||||
startIndex: 0
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -740,7 +738,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
>
|
||||
{renderColumnHeaders()}
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
<div className="mt-2">
|
||||
<TaskGroupHeader
|
||||
@@ -772,7 +770,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// For other groupings, show the empty state message
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full">
|
||||
@@ -841,51 +839,63 @@ const TaskListV2Section: React.FC = () => {
|
||||
{renderGroup(groupIndex)}
|
||||
|
||||
{/* Group Tasks */}
|
||||
{!collapsedGroups.has(group.id) && (
|
||||
group.tasks.length > 0 ? (
|
||||
group.tasks.map((task, taskIndex) => {
|
||||
const globalTaskIndex =
|
||||
virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) +
|
||||
taskIndex;
|
||||
{!collapsedGroups.has(group.id) &&
|
||||
(group.tasks.length > 0
|
||||
? group.tasks.map((task, taskIndex) => {
|
||||
const globalTaskIndex =
|
||||
virtuosoGroups
|
||||
.slice(0, groupIndex)
|
||||
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
|
||||
|
||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
||||
|
||||
// Check if we should show drop indicators
|
||||
const isTaskBeingDraggedOver = overId === task.id;
|
||||
const isGroupBeingDraggedOver = overId === group.id;
|
||||
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
|
||||
// Check if we should show drop indicators
|
||||
const isTaskBeingDraggedOver = overId === task.id;
|
||||
const isGroupBeingDraggedOver = overId === group.id;
|
||||
const isFirstTaskInGroupBeingDraggedOver =
|
||||
isFirstTaskInGroup && isTaskBeingDraggedOver;
|
||||
|
||||
return (
|
||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||
{/* Placeholder drop indicator before first task in group */}
|
||||
{isFirstTaskInGroupBeingDraggedOver && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
)}
|
||||
|
||||
{/* Placeholder drop indicator between tasks */}
|
||||
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
)}
|
||||
|
||||
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
||||
|
||||
{/* Placeholder drop indicator at end of group when dragging over group */}
|
||||
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Handle empty groups with placeholder drop indicator
|
||||
overId === group.id && (
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
return (
|
||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||
{/* Placeholder drop indicator before first task in group */}
|
||||
{isFirstTaskInGroupBeingDraggedOver && (
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Placeholder drop indicator between tasks */}
|
||||
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
||||
|
||||
{/* Placeholder drop indicator at end of group when dragging over group */}
|
||||
{isGroupBeingDraggedOver &&
|
||||
taskIndex === group.tasks.length - 1 && (
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: // Handle empty groups with placeholder drop indicator
|
||||
overId === group.id && (
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
<PlaceholderDropIndicator
|
||||
isVisible={true}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -896,7 +906,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
|
||||
{activeId ? (
|
||||
<div
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
|
||||
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
||||
>
|
||||
@@ -949,7 +959,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
|
||||
{/* Custom Column Modal */}
|
||||
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
|
||||
|
||||
|
||||
{/* Convert To Subtask Drawer */}
|
||||
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
||||
</div>
|
||||
|
||||
@@ -27,116 +27,115 @@ interface TaskRowProps {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
taskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
isSubtask = false,
|
||||
isFirstInGroup = false,
|
||||
updateTaskCustomColumnValue,
|
||||
depth = 0
|
||||
}) => {
|
||||
// Get task data and selection state from Redux
|
||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
|
||||
// Early return if task is not found
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use extracted hooks for state management
|
||||
const {
|
||||
activeDatePicker,
|
||||
setActiveDatePicker,
|
||||
editTaskName,
|
||||
setEditTaskName,
|
||||
taskName,
|
||||
setTaskName,
|
||||
taskDisplayName,
|
||||
convertedTask,
|
||||
formattedDates,
|
||||
dateValues,
|
||||
labelsAdapter,
|
||||
} = useTaskRowState(task);
|
||||
|
||||
const {
|
||||
handleCheckboxChange,
|
||||
handleTaskNameSave,
|
||||
handleTaskNameEdit,
|
||||
} = useTaskRowActions({
|
||||
task,
|
||||
const TaskRow: React.FC<TaskRowProps> = memo(
|
||||
({
|
||||
taskId,
|
||||
taskName,
|
||||
editTaskName,
|
||||
setEditTaskName,
|
||||
});
|
||||
|
||||
// Drag and drop functionality - only enable for parent tasks
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
data: {
|
||||
type: 'task',
|
||||
task,
|
||||
},
|
||||
disabled: isSubtask, // Disable drag and drop for subtasks
|
||||
});
|
||||
|
||||
// Use extracted column renderer hook
|
||||
const { renderColumn } = useTaskRowColumns({
|
||||
task,
|
||||
projectId,
|
||||
isSubtask,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
visibleColumns,
|
||||
isSubtask = false,
|
||||
isFirstInGroup = false,
|
||||
updateTaskCustomColumnValue,
|
||||
taskDisplayName,
|
||||
convertedTask,
|
||||
formattedDates,
|
||||
dateValues,
|
||||
labelsAdapter,
|
||||
activeDatePicker,
|
||||
setActiveDatePicker,
|
||||
editTaskName,
|
||||
taskName,
|
||||
setEditTaskName,
|
||||
setTaskName,
|
||||
handleCheckboxChange,
|
||||
handleTaskNameSave,
|
||||
handleTaskNameEdit,
|
||||
attributes,
|
||||
listeners,
|
||||
depth,
|
||||
});
|
||||
depth = 0,
|
||||
}) => {
|
||||
// Get task data and selection state from Redux
|
||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
|
||||
// Memoize style object to prevent unnecessary re-renders
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
|
||||
}), [transform, transition, isDragging]);
|
||||
// Early return if task is not found
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, height: '40px' }}
|
||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||
} ${
|
||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||
}`}
|
||||
>
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width, column.isSticky, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
// Use extracted hooks for state management
|
||||
const {
|
||||
activeDatePicker,
|
||||
setActiveDatePicker,
|
||||
editTaskName,
|
||||
setEditTaskName,
|
||||
taskName,
|
||||
setTaskName,
|
||||
taskDisplayName,
|
||||
convertedTask,
|
||||
formattedDates,
|
||||
dateValues,
|
||||
labelsAdapter,
|
||||
} = useTaskRowState(task);
|
||||
|
||||
const { handleCheckboxChange, handleTaskNameSave, handleTaskNameEdit } = useTaskRowActions({
|
||||
task,
|
||||
taskId,
|
||||
taskName,
|
||||
editTaskName,
|
||||
setEditTaskName,
|
||||
});
|
||||
|
||||
// Drag and drop functionality - only enable for parent tasks
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
data: {
|
||||
type: 'task',
|
||||
task,
|
||||
},
|
||||
disabled: isSubtask, // Disable drag and drop for subtasks
|
||||
});
|
||||
|
||||
// Use extracted column renderer hook
|
||||
const { renderColumn } = useTaskRowColumns({
|
||||
task,
|
||||
projectId,
|
||||
isSubtask,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
visibleColumns,
|
||||
updateTaskCustomColumnValue,
|
||||
taskDisplayName,
|
||||
convertedTask,
|
||||
formattedDates,
|
||||
dateValues,
|
||||
labelsAdapter,
|
||||
activeDatePicker,
|
||||
setActiveDatePicker,
|
||||
editTaskName,
|
||||
taskName,
|
||||
setEditTaskName,
|
||||
setTaskName,
|
||||
handleCheckboxChange,
|
||||
handleTaskNameSave,
|
||||
handleTaskNameEdit,
|
||||
attributes,
|
||||
listeners,
|
||||
depth,
|
||||
});
|
||||
|
||||
// Memoize style object to prevent unnecessary re-renders
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
|
||||
}),
|
||||
[transform, transition, isDragging]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, height: '40px' }}
|
||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||
} ${isDragging ? 'shadow-lg border border-blue-300' : ''}`}
|
||||
>
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width, column.isSticky, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TaskRow.displayName = 'TaskRow';
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { memo, useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { selectTaskById, createSubtask, selectSubtaskLoading } from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
selectTaskById,
|
||||
createSubtask,
|
||||
selectSubtaskLoading,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import TaskRow from './TaskRow';
|
||||
import SubtaskLoadingSkeleton from './SubtaskLoadingSkeleton';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
@@ -42,166 +46,194 @@ interface AddSubtaskRowProps {
|
||||
depth?: number; // Add depth prop for proper indentation
|
||||
}
|
||||
|
||||
const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
||||
parentTaskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
onSubtaskAdded,
|
||||
rowId,
|
||||
autoFocus = false,
|
||||
isActive = true,
|
||||
onActivate,
|
||||
depth = 0
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [subtaskName, setSubtaskName] = useState('');
|
||||
const inputRef = useRef<any>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket, connected } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(
|
||||
({
|
||||
parentTaskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
onSubtaskAdded,
|
||||
rowId,
|
||||
autoFocus = false,
|
||||
isActive = true,
|
||||
onActivate,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [subtaskName, setSubtaskName] = useState('');
|
||||
const inputRef = useRef<any>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket, connected } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const handleAddSubtask = useCallback(() => {
|
||||
if (!subtaskName.trim() || !currentSession) return;
|
||||
|
||||
// Create optimistic subtask immediately for better UX
|
||||
dispatch(createSubtask({
|
||||
parentTaskId,
|
||||
name: subtaskName.trim(),
|
||||
projectId
|
||||
}));
|
||||
|
||||
// Emit socket event for server-side creation
|
||||
if (connected && socket) {
|
||||
socket.emit(
|
||||
SocketEvents.QUICK_TASK.toString(),
|
||||
JSON.stringify({
|
||||
name: subtaskName.trim(),
|
||||
project_id: projectId,
|
||||
parent_task_id: parentTaskId,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the input but keep it focused for the next subtask
|
||||
setSubtaskName('');
|
||||
// Keep isAdding as true so the input stays visible
|
||||
// Focus the input again after a short delay to ensure it's ready
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Notify parent that subtask was added
|
||||
onSubtaskAdded();
|
||||
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]);
|
||||
}, [autoFocus]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSubtaskName('');
|
||||
setIsAdding(false);
|
||||
}, []);
|
||||
const handleAddSubtask = useCallback(() => {
|
||||
if (!subtaskName.trim() || !currentSession) return;
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Only cancel if the input is empty, otherwise keep it active
|
||||
if (subtaskName.trim() === '') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [subtaskName, handleCancel]);
|
||||
// Create optimistic subtask immediately for better UX
|
||||
dispatch(
|
||||
createSubtask({
|
||||
parentTaskId,
|
||||
name: subtaskName.trim(),
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleCancel]);
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return <div style={baseStyle} />;
|
||||
case 'checkbox':
|
||||
return <div style={baseStyle} />;
|
||||
case 'taskKey':
|
||||
return <div style={baseStyle} />;
|
||||
case 'description':
|
||||
return <div style={baseStyle} />;
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center h-full" style={baseStyle}>
|
||||
<div className="flex items-center w-full h-full">
|
||||
{/* Match subtask indentation pattern - reduced spacing for level 1 */}
|
||||
<div className="w-2" />
|
||||
{/* Add additional indentation for deeper levels - increased spacing for level 2+ */}
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div key={i} className="w-6" />
|
||||
))}
|
||||
<div className="w-1" />
|
||||
|
||||
{isActive ? (
|
||||
!isAdding ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onActivate) {
|
||||
onActivate();
|
||||
}
|
||||
setIsAdding(true);
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
|
||||
>
|
||||
<PlusOutlined className="text-xs" />
|
||||
{t('addSubTaskText')}
|
||||
</button>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={subtaskName}
|
||||
onChange={(e) => setSubtaskName(e.target.value)}
|
||||
onPressEnter={handleAddSubtask}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type subtask name and press Enter to save"
|
||||
className="w-full h-full border-none shadow-none bg-transparent"
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
// Empty space when not active
|
||||
<div className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
// Emit socket event for server-side creation
|
||||
if (connected && socket) {
|
||||
socket.emit(
|
||||
SocketEvents.QUICK_TASK.toString(),
|
||||
JSON.stringify({
|
||||
name: subtaskName.trim(),
|
||||
project_id: projectId,
|
||||
parent_task_id: parentTaskId,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
})
|
||||
);
|
||||
default:
|
||||
return <div style={baseStyle} />;
|
||||
}
|
||||
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, handleBlur, handleKeyDown, t, isActive, onActivate, depth]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
// Clear the input but keep it focused for the next subtask
|
||||
setSubtaskName('');
|
||||
// Keep isAdding as true so the input stays visible
|
||||
// Focus the input again after a short delay to ensure it's ready
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Notify parent that subtask was added
|
||||
onSubtaskAdded();
|
||||
}, [
|
||||
subtaskName,
|
||||
dispatch,
|
||||
parentTaskId,
|
||||
projectId,
|
||||
connected,
|
||||
socket,
|
||||
currentSession,
|
||||
onSubtaskAdded,
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSubtaskName('');
|
||||
setIsAdding(false);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Only cancel if the input is empty, otherwise keep it active
|
||||
if (subtaskName.trim() === '') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [subtaskName, handleCancel]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleCancel]
|
||||
);
|
||||
|
||||
const renderColumn = useCallback(
|
||||
(columnId: string, width: string) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return <div style={baseStyle} />;
|
||||
case 'checkbox':
|
||||
return <div style={baseStyle} />;
|
||||
case 'taskKey':
|
||||
return <div style={baseStyle} />;
|
||||
case 'description':
|
||||
return <div style={baseStyle} />;
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center h-full" style={baseStyle}>
|
||||
<div className="flex items-center w-full h-full">
|
||||
{/* Match subtask indentation pattern - reduced spacing for level 1 */}
|
||||
<div className="w-2" />
|
||||
{/* Add additional indentation for deeper levels - increased spacing for level 2+ */}
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div key={i} className="w-6" />
|
||||
))}
|
||||
<div className="w-1" />
|
||||
|
||||
{isActive ? (
|
||||
!isAdding ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onActivate) {
|
||||
onActivate();
|
||||
}
|
||||
setIsAdding(true);
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
|
||||
>
|
||||
<PlusOutlined className="text-xs" />
|
||||
{t('addSubTaskText')}
|
||||
</button>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={subtaskName}
|
||||
onChange={e => setSubtaskName(e.target.value)}
|
||||
onPressEnter={handleAddSubtask}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type subtask name and press Enter to save"
|
||||
className="w-full h-full border-none shadow-none bg-transparent"
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
// Empty space when not active
|
||||
<div className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div style={baseStyle} />;
|
||||
}
|
||||
},
|
||||
[
|
||||
isAdding,
|
||||
subtaskName,
|
||||
handleAddSubtask,
|
||||
handleCancel,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
t,
|
||||
isActive,
|
||||
onActivate,
|
||||
depth,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>{renderColumn(column.id, column.width)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AddSubtaskRow.displayName = 'AddSubtaskRow';
|
||||
|
||||
@@ -233,90 +265,98 @@ const getBorderColor = (depth: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
||||
taskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
isFirstInGroup = false,
|
||||
updateTaskCustomColumnValue,
|
||||
depth = 0,
|
||||
maxDepth = 3
|
||||
}) => {
|
||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
|
||||
const dispatch = useAppDispatch();
|
||||
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(
|
||||
({
|
||||
taskId,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
isFirstInGroup = false,
|
||||
updateTaskCustomColumnValue,
|
||||
depth = 0,
|
||||
maxDepth = 3,
|
||||
}) => {
|
||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleSubtaskAdded = useCallback(() => {
|
||||
// After adding a subtask, the AddSubtaskRow will handle its own state reset
|
||||
// We don't need to do anything here
|
||||
}, []);
|
||||
const handleSubtaskAdded = useCallback(() => {
|
||||
// After adding a subtask, the AddSubtaskRow will handle its own state reset
|
||||
// We don't need to do anything here
|
||||
}, []);
|
||||
|
||||
if (!task) {
|
||||
return null;
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render subtasks if we've reached the maximum depth
|
||||
const canHaveSubtasks = depth < maxDepth;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main task row */}
|
||||
<TaskRow
|
||||
taskId={taskId}
|
||||
projectId={projectId}
|
||||
visibleColumns={visibleColumns}
|
||||
isFirstInGroup={isFirstInGroup}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
isSubtask={depth > 0}
|
||||
depth={depth}
|
||||
/>
|
||||
|
||||
{/* Subtasks and add subtask row when expanded */}
|
||||
{canHaveSubtasks && task.show_sub_tasks && (
|
||||
<>
|
||||
{/* Show loading skeleton while fetching subtasks */}
|
||||
{isLoadingSubtasks && (
|
||||
<>
|
||||
<SubtaskLoadingSkeleton visibleColumns={visibleColumns} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Render existing subtasks when not loading - RECURSIVELY */}
|
||||
{!isLoadingSubtasks &&
|
||||
task.sub_tasks?.map((subtask: Task) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}
|
||||
>
|
||||
<TaskRowWithSubtasks
|
||||
taskId={subtask.id}
|
||||
projectId={projectId}
|
||||
visibleColumns={visibleColumns}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
depth={depth + 1}
|
||||
maxDepth={maxDepth}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add subtask row - only show when not loading */}
|
||||
{!isLoadingSubtasks && (
|
||||
<div
|
||||
className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}
|
||||
>
|
||||
<AddSubtaskRow
|
||||
parentTaskId={taskId}
|
||||
projectId={projectId}
|
||||
visibleColumns={visibleColumns}
|
||||
onSubtaskAdded={handleSubtaskAdded}
|
||||
rowId={`add-subtask-${taskId}`}
|
||||
autoFocus={false}
|
||||
isActive={true}
|
||||
onActivate={undefined}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render subtasks if we've reached the maximum depth
|
||||
const canHaveSubtasks = depth < maxDepth;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main task row */}
|
||||
<TaskRow
|
||||
taskId={taskId}
|
||||
projectId={projectId}
|
||||
visibleColumns={visibleColumns}
|
||||
isFirstInGroup={isFirstInGroup}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
isSubtask={depth > 0}
|
||||
depth={depth}
|
||||
/>
|
||||
|
||||
{/* Subtasks and add subtask row when expanded */}
|
||||
{canHaveSubtasks && task.show_sub_tasks && (
|
||||
<>
|
||||
{/* Show loading skeleton while fetching subtasks */}
|
||||
{isLoadingSubtasks && (
|
||||
<>
|
||||
<SubtaskLoadingSkeleton visibleColumns={visibleColumns} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Render existing subtasks when not loading - RECURSIVELY */}
|
||||
{!isLoadingSubtasks && task.sub_tasks?.map((subtask: Task) => (
|
||||
<div key={subtask.id} className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}>
|
||||
<TaskRowWithSubtasks
|
||||
taskId={subtask.id}
|
||||
projectId={projectId}
|
||||
visibleColumns={visibleColumns}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
depth={depth + 1}
|
||||
maxDepth={maxDepth}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add subtask row - only show when not loading */}
|
||||
{!isLoadingSubtasks && (
|
||||
<div className={`${getSubtaskBackgroundColor(depth + 1)} border-l-2 ${getBorderColor(depth + 1)}`}>
|
||||
<AddSubtaskRow
|
||||
parentTaskId={taskId}
|
||||
projectId={projectId}
|
||||
visibleColumns={visibleColumns}
|
||||
onSubtaskAdded={handleSubtaskAdded}
|
||||
rowId={`add-subtask-${taskId}`}
|
||||
autoFocus={false}
|
||||
isActive={true}
|
||||
onActivate={undefined}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
);
|
||||
|
||||
TaskRowWithSubtasks.displayName = 'TaskRowWithSubtasks';
|
||||
|
||||
export default TaskRowWithSubtasks;
|
||||
export default TaskRowWithSubtasks;
|
||||
|
||||
@@ -26,4 +26,4 @@ const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId,
|
||||
|
||||
TaskTimeTracking.displayName = 'TaskTimeTracking';
|
||||
|
||||
export default TaskTimeTracking;
|
||||
export default TaskTimeTracking;
|
||||
|
||||
@@ -21,160 +21,182 @@ interface AddTaskRowProps {
|
||||
autoFocus?: boolean; // Whether this row should auto-focus on mount
|
||||
}
|
||||
|
||||
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
groupId,
|
||||
groupType,
|
||||
groupValue,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
onTaskAdded,
|
||||
rowId,
|
||||
autoFocus = false
|
||||
}) => {
|
||||
const [isAdding, setIsAdding] = useState(autoFocus);
|
||||
const [taskName, setTaskName] = useState('');
|
||||
const inputRef = useRef<any>(null);
|
||||
const { socket, connected } = useSocket();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// Get session data for reporter_id and team_id
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const AddTaskRow: React.FC<AddTaskRowProps> = memo(
|
||||
({
|
||||
groupId,
|
||||
groupType,
|
||||
groupValue,
|
||||
projectId,
|
||||
visibleColumns,
|
||||
onTaskAdded,
|
||||
rowId,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const [isAdding, setIsAdding] = useState(autoFocus);
|
||||
const [taskName, setTaskName] = useState('');
|
||||
const inputRef = useRef<any>(null);
|
||||
const { socket, connected } = useSocket();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// Auto-focus when autoFocus prop is true
|
||||
useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
setIsAdding(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
// Get session data for reporter_id and team_id
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
// The global socket handler (useTaskSocketHandlers) will handle task addition
|
||||
// No need for local socket listener to avoid duplicate additions
|
||||
|
||||
const handleAddTask = useCallback(() => {
|
||||
if (!taskName.trim() || !currentSession) return;
|
||||
|
||||
try {
|
||||
const body: any = {
|
||||
name: taskName.trim(),
|
||||
project_id: projectId,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
// Map grouping type to correct field name expected by backend
|
||||
switch (groupType) {
|
||||
case 'status':
|
||||
body.status_id = groupValue;
|
||||
break;
|
||||
case 'priority':
|
||||
body.priority_id = groupValue;
|
||||
break;
|
||||
case 'phase':
|
||||
body.phase_id = groupValue;
|
||||
break;
|
||||
default:
|
||||
// For any other grouping types, use the groupType as is
|
||||
body[groupType] = groupValue;
|
||||
break;
|
||||
// Auto-focus when autoFocus prop is true
|
||||
useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
setIsAdding(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
if (socket && connected) {
|
||||
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
setTaskName('');
|
||||
// Keep the input active and notify parent to create new row
|
||||
onTaskAdded(rowId);
|
||||
// Task refresh will be handled by socket response listener
|
||||
} else {
|
||||
console.warn('Socket not connected, unable to create task');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
}
|
||||
}, [taskName, projectId, groupType, groupValue, socket, connected, currentSession, onTaskAdded, rowId]);
|
||||
// The global socket handler (useTaskSocketHandlers) will handle task addition
|
||||
// No need for local socket listener to avoid duplicate additions
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (taskName.trim() === '') {
|
||||
setTaskName('');
|
||||
setIsAdding(false);
|
||||
}
|
||||
}, [taskName]);
|
||||
const handleAddTask = useCallback(() => {
|
||||
if (!taskName.trim() || !currentSession) return;
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleCancel]);
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
case 'checkbox':
|
||||
case 'taskKey':
|
||||
case 'description':
|
||||
return <div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />;
|
||||
case 'labels':
|
||||
const labelsStyle = {
|
||||
...baseStyle,
|
||||
...(width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {})
|
||||
try {
|
||||
const body: any = {
|
||||
name: taskName.trim(),
|
||||
project_id: projectId,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
return <div className="border-r border-gray-200 dark:border-gray-700" style={labelsStyle} />;
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center h-full" style={baseStyle}>
|
||||
<div className="flex items-center w-full h-full">
|
||||
<div className="w-1 mr-1" />
|
||||
|
||||
{!isAdding ? (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
|
||||
>
|
||||
<PlusOutlined className="text-xs" />
|
||||
{t('addTaskText')}
|
||||
</button>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={taskName}
|
||||
onChange={(e) => setTaskName(e.target.value)}
|
||||
onPressEnter={handleAddTask}
|
||||
onBlur={handleCancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type task name and press Enter to save"
|
||||
className="w-full h-full border-none shadow-none bg-transparent"
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />;
|
||||
}
|
||||
}, [isAdding, taskName, handleAddTask, handleCancel, handleKeyDown, t]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px]">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
// Map grouping type to correct field name expected by backend
|
||||
switch (groupType) {
|
||||
case 'status':
|
||||
body.status_id = groupValue;
|
||||
break;
|
||||
case 'priority':
|
||||
body.priority_id = groupValue;
|
||||
break;
|
||||
case 'phase':
|
||||
body.phase_id = groupValue;
|
||||
break;
|
||||
default:
|
||||
// For any other grouping types, use the groupType as is
|
||||
body[groupType] = groupValue;
|
||||
break;
|
||||
}
|
||||
|
||||
if (socket && connected) {
|
||||
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
setTaskName('');
|
||||
// Keep the input active and notify parent to create new row
|
||||
onTaskAdded(rowId);
|
||||
// Task refresh will be handled by socket response listener
|
||||
} else {
|
||||
console.warn('Socket not connected, unable to create task');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
}
|
||||
}, [
|
||||
taskName,
|
||||
projectId,
|
||||
groupType,
|
||||
groupValue,
|
||||
socket,
|
||||
connected,
|
||||
currentSession,
|
||||
onTaskAdded,
|
||||
rowId,
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (taskName.trim() === '') {
|
||||
setTaskName('');
|
||||
setIsAdding(false);
|
||||
}
|
||||
}, [taskName]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleCancel]
|
||||
);
|
||||
|
||||
const renderColumn = useCallback(
|
||||
(columnId: string, width: string) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
case 'checkbox':
|
||||
case 'taskKey':
|
||||
case 'description':
|
||||
return (
|
||||
<div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />
|
||||
);
|
||||
case 'labels':
|
||||
const labelsStyle = {
|
||||
...baseStyle,
|
||||
...(width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}),
|
||||
};
|
||||
return (
|
||||
<div className="border-r border-gray-200 dark:border-gray-700" style={labelsStyle} />
|
||||
);
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center h-full" style={baseStyle}>
|
||||
<div className="flex items-center w-full h-full">
|
||||
<div className="w-1 mr-1" />
|
||||
|
||||
{!isAdding ? (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
|
||||
>
|
||||
<PlusOutlined className="text-xs" />
|
||||
{t('addTaskText')}
|
||||
</button>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={taskName}
|
||||
onChange={e => setTaskName(e.target.value)}
|
||||
onPressEnter={handleAddTask}
|
||||
onBlur={handleCancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type task name and press Enter to save"
|
||||
className="w-full h-full border-none shadow-none bg-transparent"
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="border-r border-gray-200 dark:border-gray-700" style={baseStyle} />
|
||||
);
|
||||
}
|
||||
},
|
||||
[isAdding, taskName, handleAddTask, handleCancel, handleKeyDown, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px]">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>{renderColumn(column.id, column.width)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AddTaskRow.displayName = 'AddTaskRow';
|
||||
|
||||
export default AddTaskRow;
|
||||
export default AddTaskRow;
|
||||
|
||||
@@ -31,22 +31,26 @@ export const AddCustomColumnButton: React.FC = memo(() => {
|
||||
className={`
|
||||
group relative w-9 h-9 rounded-lg border-2 border-dashed transition-all duration-200
|
||||
flex items-center justify-center
|
||||
${isDarkMode
|
||||
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
|
||||
: 'border-gray-300 hover:border-blue-500 hover:bg-blue-50 text-gray-400 hover:text-blue-600'
|
||||
${
|
||||
isDarkMode
|
||||
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
|
||||
: 'border-gray-300 hover:border-blue-500 hover:bg-blue-50 text-gray-400 hover:text-blue-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<PlusOutlined className="text-sm transition-transform duration-200 group-hover:scale-110" />
|
||||
|
||||
|
||||
{/* Subtle glow effect on hover */}
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||
${isDarkMode
|
||||
? 'bg-blue-500/5 shadow-lg shadow-blue-500/20'
|
||||
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
|
||||
${
|
||||
isDarkMode
|
||||
? 'bg-blue-500/5 shadow-lg shadow-blue-500/20'
|
||||
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
|
||||
}
|
||||
`} />
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -62,22 +66,25 @@ export const CustomColumnHeader: React.FC<{
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const displayName = column.name ||
|
||||
column.label ||
|
||||
column.custom_column_obj?.fieldTitle ||
|
||||
column.custom_column_obj?.field_title ||
|
||||
t('customColumns.customColumnHeader');
|
||||
const displayName =
|
||||
column.name ||
|
||||
column.label ||
|
||||
column.custom_column_obj?.fieldTitle ||
|
||||
column.custom_column_obj?.field_title ||
|
||||
t('customColumns.customColumnHeader');
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
className="w-full px-2 group cursor-pointer"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onSettingsClick(column.key || column.id)}
|
||||
>
|
||||
<span title={displayName} className="truncate flex-1 mr-2">{displayName}</span>
|
||||
<span title={displayName} className="truncate flex-1 mr-2">
|
||||
{displayName}
|
||||
</span>
|
||||
<Tooltip title={t('customColumns.customColumnSettings')}>
|
||||
<SettingOutlined
|
||||
className={`hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-200 flex-shrink-0 ${
|
||||
@@ -145,7 +152,9 @@ export const CustomColumnCell: React.FC<{
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>;
|
||||
return (
|
||||
<span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -161,11 +170,11 @@ export const PeopleCustomColumnCell: React.FC<{
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
|
||||
const [optimisticSelectedIds, setOptimisticSelectedIds] = useState<string[]>([]);
|
||||
|
||||
|
||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
|
||||
|
||||
// Parse selected member IDs from custom value
|
||||
const selectedMemberIds = useMemo(() => {
|
||||
try {
|
||||
@@ -199,31 +208,34 @@ export const PeopleCustomColumnCell: React.FC<{
|
||||
return members.data.filter(member => displayedMemberIds.includes(member.id));
|
||||
}, [members, displayedMemberIds]);
|
||||
|
||||
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
|
||||
// Add to pending changes for visual feedback
|
||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||
const handleMemberToggle = useCallback(
|
||||
(memberId: string, checked: boolean) => {
|
||||
// Add to pending changes for visual feedback
|
||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||
|
||||
const newSelectedIds = checked
|
||||
? [...selectedMemberIds, memberId]
|
||||
: selectedMemberIds.filter((id: string) => id !== memberId);
|
||||
const newSelectedIds = checked
|
||||
? [...selectedMemberIds, memberId]
|
||||
: selectedMemberIds.filter((id: string) => id !== memberId);
|
||||
|
||||
// Update optimistic state immediately for instant UI feedback
|
||||
setOptimisticSelectedIds(newSelectedIds);
|
||||
// Update optimistic state immediately for instant UI feedback
|
||||
setOptimisticSelectedIds(newSelectedIds);
|
||||
|
||||
if (task.id) {
|
||||
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
|
||||
}
|
||||
if (task.id) {
|
||||
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
|
||||
}
|
||||
|
||||
// Remove from pending changes after socket update is processed
|
||||
// Use a longer timeout to ensure the socket update has been received and processed
|
||||
setTimeout(() => {
|
||||
setPendingChanges(prev => {
|
||||
const newSet = new Set<string>(Array.from(prev));
|
||||
newSet.delete(memberId);
|
||||
return newSet;
|
||||
});
|
||||
}, 1500); // Even longer delay to ensure socket update is fully processed
|
||||
}, [selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]);
|
||||
// Remove from pending changes after socket update is processed
|
||||
// Use a longer timeout to ensure the socket update has been received and processed
|
||||
setTimeout(() => {
|
||||
setPendingChanges(prev => {
|
||||
const newSet = new Set<string>(Array.from(prev));
|
||||
newSet.delete(memberId);
|
||||
return newSet;
|
||||
});
|
||||
}, 1500); // Even longer delay to ensure socket update is fully processed
|
||||
},
|
||||
[selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]
|
||||
);
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
if (members?.data?.length === 0) {
|
||||
@@ -249,7 +261,7 @@ export const PeopleCustomColumnCell: React.FC<{
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<PeopleDropdown
|
||||
selectedMemberIds={displayedMemberIds}
|
||||
onMemberToggle={handleMemberToggle}
|
||||
@@ -291,7 +303,7 @@ export const DateCustomColumnCell: React.FC<{
|
||||
onOpenChange={setIsOpen}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
placeholder={dateValue ? "" : "Set date"}
|
||||
placeholder={dateValue ? '' : 'Set date'}
|
||||
format="MMM DD, YYYY"
|
||||
suffixIcon={null}
|
||||
size="small"
|
||||
@@ -302,7 +314,7 @@ export const DateCustomColumnCell: React.FC<{
|
||||
`}
|
||||
popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'}
|
||||
inputReadOnly
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
getPopupContainer={trigger => trigger.parentElement || document.body}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
@@ -328,7 +340,7 @@ export const NumberCustomColumnCell: React.FC<{
|
||||
const [inputValue, setInputValue] = useState(String(customValue || ''));
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||
|
||||
|
||||
const numberType = columnObj?.numberType || 'formatted';
|
||||
const decimals = columnObj?.decimals || 0;
|
||||
const label = columnObj?.label || '';
|
||||
@@ -378,21 +390,23 @@ export const NumberCustomColumnCell: React.FC<{
|
||||
|
||||
const getDisplayValue = () => {
|
||||
if (isEditing) return inputValue;
|
||||
|
||||
|
||||
// Safely convert inputValue to string to avoid .trim() errors
|
||||
const stringValue = String(inputValue || '');
|
||||
if (!stringValue || stringValue.trim() === '') return '';
|
||||
|
||||
|
||||
const numValue = parseFloat(stringValue);
|
||||
if (isNaN(numValue)) return ''; // Return empty string instead of showing NaN
|
||||
|
||||
|
||||
switch (numberType) {
|
||||
case 'formatted':
|
||||
return numValue.toFixed(decimals);
|
||||
case 'percentage':
|
||||
return `${numValue.toFixed(decimals)}%`;
|
||||
case 'withLabel':
|
||||
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
|
||||
return labelPosition === 'left'
|
||||
? `${label} ${numValue.toFixed(decimals)}`
|
||||
: `${numValue.toFixed(decimals)} ${label}`;
|
||||
default:
|
||||
return numValue.toString();
|
||||
}
|
||||
@@ -442,8 +456,10 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||
const selectionsList = columnObj?.selectionsList || [];
|
||||
|
||||
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
||||
|
||||
const selectedOption = selectionsList.find(
|
||||
(option: any) => option.selection_name === customValue
|
||||
);
|
||||
|
||||
const handleOptionSelect = async (option: any) => {
|
||||
if (!task.id) return;
|
||||
@@ -454,7 +470,7 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
try {
|
||||
// Send the update to the server - Redux store will be updated immediately
|
||||
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
||||
|
||||
|
||||
// Short loading state for visual feedback
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
@@ -466,24 +482,26 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
rounded-lg shadow-xl border min-w-[180px] max-h-64 overflow-y-auto custom-column-dropdown
|
||||
${isDarkMode
|
||||
? 'bg-gray-800 border-gray-600'
|
||||
: 'bg-white border-gray-200'
|
||||
}
|
||||
`}>
|
||||
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2 border-b text-xs font-medium
|
||||
${isDarkMode
|
||||
? 'border-gray-600 text-gray-300 bg-gray-750'
|
||||
: 'border-gray-200 text-gray-600 bg-gray-50'
|
||||
${
|
||||
isDarkMode
|
||||
? 'border-gray-600 text-gray-300 bg-gray-750'
|
||||
: 'border-gray-200 text-gray-600 bg-gray-50'
|
||||
}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
Select option
|
||||
</div>
|
||||
|
||||
|
||||
{/* Options */}
|
||||
<div className="p-1">
|
||||
{selectionsList.map((option: any) => (
|
||||
@@ -492,13 +510,14 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
onClick={() => handleOptionSelect(option)}
|
||||
className={`
|
||||
flex items-center gap-3 p-2 rounded-md cursor-pointer transition-all duration-200
|
||||
${selectedOption?.selection_id === option.selection_id
|
||||
? isDarkMode
|
||||
? 'bg-blue-900/50 text-blue-200'
|
||||
: 'bg-blue-50 text-blue-700'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700 text-gray-200'
|
||||
: 'hover:bg-gray-100 text-gray-900'
|
||||
${
|
||||
selectedOption?.selection_id === option.selection_id
|
||||
? isDarkMode
|
||||
? 'bg-blue-900/50 text-blue-200'
|
||||
: 'bg-blue-50 text-blue-700'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700 text-gray-200'
|
||||
: 'hover:bg-gray-100 text-gray-900'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -508,23 +527,31 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
/>
|
||||
<span className="text-sm font-medium flex-1">{option.selection_name}</span>
|
||||
{selectedOption?.selection_id === option.selection_id && (
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
w-4 h-4 rounded-full flex items-center justify-center
|
||||
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{selectionsList.length === 0 && (
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
text-center py-8 text-sm
|
||||
${isDarkMode ? 'text-gray-500' : 'text-gray-400'}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
<div className="mb-2">📋</div>
|
||||
<div>No options available</div>
|
||||
</div>
|
||||
@@ -534,7 +561,9 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'custom-column-focused' : ''}`}>
|
||||
<div
|
||||
className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'custom-column-focused' : ''}`}
|
||||
>
|
||||
<Dropdown
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
@@ -542,25 +571,30 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
overlayClassName="custom-selection-dropdown"
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
getPopupContainer={trigger => trigger.parentElement || document.body}
|
||||
>
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 min-h-[28px] transition-all duration-200 relative
|
||||
${isDropdownOpen
|
||||
? isDarkMode
|
||||
? 'bg-gray-700 ring-1 ring-blue-500/50'
|
||||
: 'bg-gray-100 ring-1 ring-blue-500/50'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700/50'
|
||||
: 'hover:bg-gray-100/50'
|
||||
${
|
||||
isDropdownOpen
|
||||
? isDarkMode
|
||||
? 'bg-gray-700 ring-1 ring-blue-500/50'
|
||||
: 'bg-gray-100 ring-1 ring-blue-500/50'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700/50'
|
||||
: 'hover:bg-gray-100/50'
|
||||
}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`
|
||||
<div
|
||||
className={`
|
||||
w-3 h-3 rounded-full animate-spin border-2 border-transparent
|
||||
${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'}
|
||||
`} />
|
||||
`}
|
||||
/>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Updating...
|
||||
</span>
|
||||
@@ -571,21 +605,45 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
|
||||
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }}
|
||||
/>
|
||||
<span className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-900'}`}>
|
||||
<span
|
||||
className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-900'}`}
|
||||
>
|
||||
{selectedOption.selection_name}
|
||||
</span>
|
||||
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<svg
|
||||
className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}
|
||||
/>
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||
Select
|
||||
</span>
|
||||
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<svg
|
||||
className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
@@ -595,4 +653,4 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
);
|
||||
});
|
||||
|
||||
SelectionCustomColumnCell.displayName = 'SelectionCustomColumnCell';
|
||||
SelectionCustomColumnCell.displayName = 'SelectionCustomColumnCell';
|
||||
|
||||
@@ -18,119 +18,127 @@ interface DatePickerColumnProps {
|
||||
onActiveDatePickerChange: (field: string | null) => void;
|
||||
}
|
||||
|
||||
export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(({
|
||||
width,
|
||||
task,
|
||||
field,
|
||||
formattedDate,
|
||||
dateValue,
|
||||
isDarkMode,
|
||||
activeDatePicker,
|
||||
onActiveDatePickerChange
|
||||
}) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
export const DatePickerColumn: React.FC<DatePickerColumnProps> = memo(
|
||||
({
|
||||
width,
|
||||
task,
|
||||
field,
|
||||
formattedDate,
|
||||
dateValue,
|
||||
isDarkMode,
|
||||
activeDatePicker,
|
||||
onActiveDatePickerChange,
|
||||
}) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// Handle date change
|
||||
const handleDateChange = useCallback(
|
||||
(date: dayjs.Dayjs | null) => {
|
||||
if (!connected || !socket) return;
|
||||
// Handle date change
|
||||
const handleDateChange = useCallback(
|
||||
(date: dayjs.Dayjs | null) => {
|
||||
if (!connected || !socket) return;
|
||||
|
||||
const eventType =
|
||||
field === 'startDate'
|
||||
? SocketEvents.TASK_START_DATE_CHANGE
|
||||
: SocketEvents.TASK_END_DATE_CHANGE;
|
||||
const dateField = field === 'startDate' ? 'start_date' : 'end_date';
|
||||
const eventType =
|
||||
field === 'startDate'
|
||||
? SocketEvents.TASK_START_DATE_CHANGE
|
||||
: SocketEvents.TASK_END_DATE_CHANGE;
|
||||
const dateField = field === 'startDate' ? 'start_date' : 'end_date';
|
||||
|
||||
socket.emit(
|
||||
eventType.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
[dateField]: date?.format('YYYY-MM-DD'),
|
||||
parent_task: null,
|
||||
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
);
|
||||
socket.emit(
|
||||
eventType.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
[dateField]: date?.format('YYYY-MM-DD'),
|
||||
parent_task: null,
|
||||
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
);
|
||||
|
||||
// Close the date picker after selection
|
||||
onActiveDatePickerChange(null);
|
||||
},
|
||||
[connected, socket, task.id, field, onActiveDatePickerChange]
|
||||
);
|
||||
// Close the date picker after selection
|
||||
onActiveDatePickerChange(null);
|
||||
},
|
||||
[connected, socket, task.id, field, onActiveDatePickerChange]
|
||||
);
|
||||
|
||||
// Handle clear date
|
||||
const handleClearDate = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDateChange(null);
|
||||
}, [handleDateChange]);
|
||||
// Handle clear date
|
||||
const handleClearDate = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDateChange(null);
|
||||
},
|
||||
[handleDateChange]
|
||||
);
|
||||
|
||||
// Handle open date picker
|
||||
const handleOpenDatePicker = useCallback(() => {
|
||||
onActiveDatePickerChange(field);
|
||||
}, [field, onActiveDatePickerChange]);
|
||||
// Handle open date picker
|
||||
const handleOpenDatePicker = useCallback(() => {
|
||||
onActiveDatePickerChange(field);
|
||||
}, [field, onActiveDatePickerChange]);
|
||||
|
||||
const isActive = activeDatePicker === field;
|
||||
const placeholder = field === 'dueDate' ? t('dueDatePlaceholder') : t('startDatePlaceholder');
|
||||
const clearTitle = field === 'dueDate' ? t('clearDueDate') : t('clearStartDate');
|
||||
const setTitle = field === 'dueDate' ? t('setDueDate') : t('setStartDate');
|
||||
const isActive = activeDatePicker === field;
|
||||
const placeholder = field === 'dueDate' ? t('dueDatePlaceholder') : t('startDatePlaceholder');
|
||||
const clearTitle = field === 'dueDate' ? t('clearDueDate') : t('clearStartDate');
|
||||
const setTitle = field === 'dueDate' ? t('setDueDate') : t('setStartDate');
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2 relative group border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
{isActive ? (
|
||||
<div className="w-full relative">
|
||||
<DatePicker
|
||||
{...taskManagementAntdConfig.datePickerDefaults}
|
||||
className="w-full bg-transparent border-none shadow-none"
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={false}
|
||||
suffixIcon={null}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onActiveDatePickerChange(null);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 relative group border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{isActive ? (
|
||||
<div className="w-full relative">
|
||||
<DatePicker
|
||||
{...taskManagementAntdConfig.datePickerDefaults}
|
||||
className="w-full bg-transparent border-none shadow-none"
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={false}
|
||||
suffixIcon={null}
|
||||
open={true}
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
onActiveDatePickerChange(null);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{/* Custom clear button */}
|
||||
{dateValue && (
|
||||
<button
|
||||
onClick={handleClearDate}
|
||||
className={`absolute right-1 top-1/2 transform -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
title={clearTitle}
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: '10px' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors text-center"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleOpenDatePicker();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{/* Custom clear button */}
|
||||
{dateValue && (
|
||||
<button
|
||||
onClick={handleClearDate}
|
||||
className={`absolute right-1 top-1/2 transform -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded-full text-xs ${
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
title={clearTitle}
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: '10px' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded px-2 py-1 transition-colors text-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenDatePicker();
|
||||
}}
|
||||
>
|
||||
{formattedDate ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||
{setTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
>
|
||||
{formattedDate ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||
{setTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DatePickerColumn.displayName = 'DatePickerColumn';
|
||||
DatePickerColumn.displayName = 'DatePickerColumn';
|
||||
|
||||
@@ -82,7 +82,7 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
|
||||
|
||||
// Immediate UI update - add current user to assignees
|
||||
const currentUser = {
|
||||
id: currentSession.team_member_id,
|
||||
@@ -97,7 +97,7 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
|
||||
// Check if current user is already assigned
|
||||
const isAlreadyAssigned = updatedAssignees.includes(currentSession.team_member_id);
|
||||
|
||||
|
||||
if (!isAlreadyAssigned) {
|
||||
// Add current user to assignees for immediate UI feedback
|
||||
const newAssignees = [...updatedAssignees, currentSession.team_member_id];
|
||||
@@ -136,7 +136,16 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
setUpdatingAssignToMe(false);
|
||||
onClose();
|
||||
}
|
||||
}, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]);
|
||||
}, [
|
||||
projectId,
|
||||
task.id,
|
||||
task.assignees,
|
||||
task.assignee_names,
|
||||
currentSession,
|
||||
dispatch,
|
||||
onClose,
|
||||
trackMixpanelEvent,
|
||||
]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
@@ -256,47 +265,53 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
let options: { key: string; label: React.ReactNode; onClick: () => void }[] = [];
|
||||
|
||||
if (currentGrouping === IGroupBy.STATUS) {
|
||||
options = statusList.filter(status => status.id).map(status => ({
|
||||
key: status.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: status.color_code }}
|
||||
></span>
|
||||
<span>{status.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handleStatusMoveTo(status.id!),
|
||||
}));
|
||||
options = statusList
|
||||
.filter(status => status.id)
|
||||
.map(status => ({
|
||||
key: status.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: status.color_code }}
|
||||
></span>
|
||||
<span>{status.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handleStatusMoveTo(status.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PRIORITY) {
|
||||
options = priorityList.filter(priority => priority.id).map(priority => ({
|
||||
key: priority.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: priority.color_code }}
|
||||
></span>
|
||||
<span>{priority.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handlePriorityMoveTo(priority.id!),
|
||||
}));
|
||||
options = priorityList
|
||||
.filter(priority => priority.id)
|
||||
.map(priority => ({
|
||||
key: priority.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: priority.color_code }}
|
||||
></span>
|
||||
<span>{priority.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handlePriorityMoveTo(priority.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PHASE) {
|
||||
options = phaseList.filter(phase => phase.id).map(phase => ({
|
||||
key: phase.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: phase.color_code }}
|
||||
></span>
|
||||
<span>{phase.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handlePhaseMoveTo(phase.id!),
|
||||
}));
|
||||
options = phaseList
|
||||
.filter(phase => phase.id)
|
||||
.map(phase => ({
|
||||
key: phase.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: phase.color_code }}
|
||||
></span>
|
||||
<span>{phase.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handlePhaseMoveTo(phase.id!),
|
||||
}));
|
||||
}
|
||||
return options;
|
||||
}, [
|
||||
@@ -430,24 +445,25 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
total_minutes: task.timeTracking?.logged || 0,
|
||||
progress: task.progress,
|
||||
sub_tasks_count: task.sub_tasks_count || 0,
|
||||
assignees: task.assignees?.map((assigneeId: string) => ({
|
||||
id: assigneeId,
|
||||
name: '',
|
||||
email: '',
|
||||
avatar_url: '',
|
||||
team_member_id: assigneeId,
|
||||
project_member_id: assigneeId,
|
||||
})) || [],
|
||||
assignees:
|
||||
task.assignees?.map((assigneeId: string) => ({
|
||||
id: assigneeId,
|
||||
name: '',
|
||||
email: '',
|
||||
avatar_url: '',
|
||||
team_member_id: assigneeId,
|
||||
project_member_id: assigneeId,
|
||||
})) || [],
|
||||
labels: task.labels || [],
|
||||
manual_progress: false,
|
||||
created_at: task.createdAt,
|
||||
updated_at: task.updatedAt,
|
||||
sort_order: task.order,
|
||||
};
|
||||
|
||||
|
||||
// Select the task in bulk action reducer
|
||||
dispatch(selectTasks([projectTask]));
|
||||
|
||||
|
||||
// Open the drawer
|
||||
dispatch(setConvertToSubtaskDrawerOpen(true));
|
||||
}}
|
||||
@@ -526,4 +542,4 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskContextMenu;
|
||||
export default TaskContextMenu;
|
||||
|
||||
@@ -27,7 +27,10 @@ const TaskListSkeleton: React.FC<TaskListSkeletonProps> = ({ visibleColumns }) =
|
||||
|
||||
// Generate multiple skeleton rows
|
||||
const skeletonRows = Array.from({ length: 8 }, (_, index) => (
|
||||
<div key={index} className="flex items-center min-w-max px-1 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center min-w-max px-1 py-3 border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
{columns.map((column, colIndex) => {
|
||||
const columnStyle = {
|
||||
width: column.width,
|
||||
@@ -61,7 +64,7 @@ const TaskListSkeleton: React.FC<TaskListSkeletonProps> = ({ visibleColumns }) =
|
||||
));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||
{/* Table Container */}
|
||||
<div
|
||||
@@ -73,106 +76,106 @@ const TaskListSkeleton: React.FC<TaskListSkeletonProps> = ({ visibleColumns }) =
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Skeleton Content */}
|
||||
<div
|
||||
className="flex-1 bg-white dark:bg-gray-900 relative"
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* Skeleton Column Headers */}
|
||||
{/* Skeleton Content */}
|
||||
<div
|
||||
className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
|
||||
style={{ width: '100%', minWidth: 'max-content' }}
|
||||
className="flex-1 bg-white dark:bg-gray-900 relative"
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* Skeleton Column Headers */}
|
||||
<div
|
||||
className="flex items-center px-1 py-3 w-full"
|
||||
style={{ minWidth: 'max-content', height: '44px' }}
|
||||
className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
|
||||
style={{ width: '100%', minWidth: 'max-content' }}
|
||||
>
|
||||
{columns.map((column, index) => {
|
||||
const columnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`header-${column.id}`}
|
||||
className="border-r border-gray-200 dark:border-gray-700 flex items-center px-2"
|
||||
style={columnStyle}
|
||||
>
|
||||
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
|
||||
<span></span>
|
||||
) : (
|
||||
<Skeleton.Button size="small" active style={{ width: '60%' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Add Custom Column Button Skeleton */}
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width: '50px', flexShrink: 0 }}
|
||||
className="flex items-center px-1 py-3 w-full"
|
||||
style={{ minWidth: 'max-content', height: '44px' }}
|
||||
>
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
{columns.map((column, index) => {
|
||||
const columnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`header-${column.id}`}
|
||||
className="border-r border-gray-200 dark:border-gray-700 flex items-center px-2"
|
||||
style={columnStyle}
|
||||
>
|
||||
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
|
||||
<span></span>
|
||||
) : (
|
||||
<Skeleton.Button size="small" active style={{ width: '60%' }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Add Custom Column Button Skeleton */}
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width: '50px', flexShrink: 0 }}
|
||||
>
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton Group Headers and Rows */}
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
{/* First Group */}
|
||||
<div className="mt-2">
|
||||
{/* Group Header Skeleton */}
|
||||
<div className="flex items-center px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton.Input size="small" active style={{ width: '150px' }} />
|
||||
{/* Skeleton Group Headers and Rows */}
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
{/* First Group */}
|
||||
<div className="mt-2">
|
||||
{/* Group Header Skeleton */}
|
||||
<div className="flex items-center px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton.Input size="small" active style={{ width: '150px' }} />
|
||||
</div>
|
||||
<Skeleton.Button size="small" active style={{ width: '30px' }} />
|
||||
</div>
|
||||
<Skeleton.Button size="small" active style={{ width: '30px' }} />
|
||||
</div>
|
||||
|
||||
{/* Group Tasks Skeleton */}
|
||||
{skeletonRows.slice(0, 3)}
|
||||
</div>
|
||||
|
||||
{/* Second Group */}
|
||||
<div className="mt-2">
|
||||
{/* Group Header Skeleton */}
|
||||
<div className="flex items-center px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton.Input size="small" active style={{ width: '150px' }} />
|
||||
</div>
|
||||
<Skeleton.Button size="small" active style={{ width: '30px' }} />
|
||||
{/* Group Tasks Skeleton */}
|
||||
{skeletonRows.slice(0, 3)}
|
||||
</div>
|
||||
|
||||
{/* Group Tasks Skeleton */}
|
||||
{skeletonRows.slice(3, 6)}
|
||||
</div>
|
||||
|
||||
{/* Third Group */}
|
||||
<div className="mt-2">
|
||||
{/* Group Header Skeleton */}
|
||||
<div className="flex items-center px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton.Input size="small" active style={{ width: '150px' }} />
|
||||
{/* Second Group */}
|
||||
<div className="mt-2">
|
||||
{/* Group Header Skeleton */}
|
||||
<div className="flex items-center px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton.Input size="small" active style={{ width: '150px' }} />
|
||||
</div>
|
||||
<Skeleton.Button size="small" active style={{ width: '30px' }} />
|
||||
</div>
|
||||
<Skeleton.Button size="small" active style={{ width: '30px' }} />
|
||||
|
||||
{/* Group Tasks Skeleton */}
|
||||
{skeletonRows.slice(3, 6)}
|
||||
</div>
|
||||
|
||||
{/* Third Group */}
|
||||
<div className="mt-2">
|
||||
{/* Group Header Skeleton */}
|
||||
<div className="flex items-center px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<Skeleton.Button size="small" shape="circle" active />
|
||||
<div className="ml-3 flex-1">
|
||||
<Skeleton.Input size="small" active style={{ width: '150px' }} />
|
||||
</div>
|
||||
<Skeleton.Button size="small" active style={{ width: '30px' }} />
|
||||
</div>
|
||||
|
||||
{/* Group Tasks Skeleton */}
|
||||
{skeletonRows.slice(6, 8)}
|
||||
</div>
|
||||
|
||||
{/* Group Tasks Skeleton */}
|
||||
{skeletonRows.slice(6, 8)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListSkeleton;
|
||||
export default TaskListSkeleton;
|
||||
|
||||
@@ -55,11 +55,7 @@ export const TaskLabelsCell: React.FC<TaskLabelsCellProps> = memo(({ labels, isD
|
||||
color={label.color}
|
||||
/>
|
||||
) : (
|
||||
<CustomColordLabel
|
||||
key={`${label.id}-${index}`}
|
||||
label={label}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<CustomColordLabel key={`${label.id}-${index}`} label={label} isDarkMode={isDarkMode} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -75,15 +71,17 @@ interface DragHandleColumnProps {
|
||||
listeners: any;
|
||||
}
|
||||
|
||||
export const DragHandleColumn: React.FC<DragHandleColumnProps> = memo(({ width, isSubtask, attributes, listeners }) => (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width }}
|
||||
{...(isSubtask ? {} : { ...attributes, ...listeners })}
|
||||
>
|
||||
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
|
||||
</div>
|
||||
));
|
||||
export const DragHandleColumn: React.FC<DragHandleColumnProps> = memo(
|
||||
({ width, isSubtask, attributes, listeners }) => (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width }}
|
||||
{...(isSubtask ? {} : { ...attributes, ...listeners })}
|
||||
>
|
||||
{!isSubtask && <HolderOutlined className="text-gray-400 hover:text-gray-600" />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DragHandleColumn.displayName = 'DragHandleColumn';
|
||||
|
||||
@@ -93,15 +91,17 @@ interface CheckboxColumnProps {
|
||||
onCheckboxChange: (e: any) => void;
|
||||
}
|
||||
|
||||
export const CheckboxColumn: React.FC<CheckboxColumnProps> = memo(({ width, isSelected, onCheckboxChange }) => (
|
||||
<div className="flex items-center justify-center dark:border-gray-700" style={{ width }}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onCheckboxChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
export const CheckboxColumn: React.FC<CheckboxColumnProps> = memo(
|
||||
({ width, isSelected, onCheckboxChange }) => (
|
||||
<div className="flex items-center justify-center dark:border-gray-700" style={{ width }}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onCheckboxChange}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
CheckboxColumn.displayName = 'CheckboxColumn';
|
||||
|
||||
@@ -111,7 +111,10 @@ interface TaskKeyColumnProps {
|
||||
}
|
||||
|
||||
export const TaskKeyColumn: React.FC<TaskKeyColumnProps> = memo(({ width, taskKey }) => (
|
||||
<div className="flex items-center pl-3 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center pl-3 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<span className="text-xs font-medium px-2 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 whitespace-nowrap border border-gray-200 dark:border-gray-600">
|
||||
{taskKey || 'N/A'}
|
||||
</span>
|
||||
@@ -125,22 +128,27 @@ interface DescriptionColumnProps {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(({ width, description }) => (
|
||||
<div className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
export const DescriptionColumn: React.FC<DescriptionColumnProps> = memo(
|
||||
({ width, description }) => (
|
||||
<div
|
||||
className="text-sm text-gray-600 dark:text-gray-400 truncate w-full"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxHeight: '24px',
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
title={description || ''}
|
||||
dangerouslySetInnerHTML={{ __html: description || '' }}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
className="flex items-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<div
|
||||
className="text-sm text-gray-600 dark:text-gray-400 truncate w-full"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxHeight: '24px',
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
title={description || ''}
|
||||
dangerouslySetInnerHTML={{ __html: description || '' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DescriptionColumn.displayName = 'DescriptionColumn';
|
||||
|
||||
@@ -151,15 +159,16 @@ interface StatusColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const StatusColumn: React.FC<StatusColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<TaskStatusDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
export const StatusColumn: React.FC<StatusColumnProps> = memo(
|
||||
({ width, task, projectId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskStatusDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
StatusColumn.displayName = 'StatusColumn';
|
||||
|
||||
@@ -170,21 +179,22 @@ interface AssigneesColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const AssigneesColumn: React.FC<AssigneesColumnProps> = memo(({ width, task, convertedTask, isDarkMode }) => (
|
||||
<div className="flex items-center gap-1 px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<AvatarGroup
|
||||
members={task.assignee_names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
size={24}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
task={convertedTask}
|
||||
groupId={null}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
export const AssigneesColumn: React.FC<AssigneesColumnProps> = memo(
|
||||
({ width, task, convertedTask, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<AvatarGroup
|
||||
members={task.assignee_names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
size={24}
|
||||
/>
|
||||
<AssigneeSelector task={convertedTask} groupId={null} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
AssigneesColumn.displayName = 'AssigneesColumn';
|
||||
|
||||
@@ -195,15 +205,16 @@ interface PriorityColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const PriorityColumn: React.FC<PriorityColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<TaskPriorityDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
export const PriorityColumn: React.FC<PriorityColumnProps> = memo(
|
||||
({ width, task, projectId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskPriorityDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
PriorityColumn.displayName = 'PriorityColumn';
|
||||
|
||||
@@ -213,7 +224,10 @@ interface ProgressColumnProps {
|
||||
}
|
||||
|
||||
export const ProgressColumn: React.FC<ProgressColumnProps> = memo(({ width, task }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{task.progress !== undefined &&
|
||||
task.progress >= 0 &&
|
||||
(task.progress === 100 ? (
|
||||
@@ -227,10 +241,7 @@ export const ProgressColumn: React.FC<ProgressColumnProps> = memo(({ width, task
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TaskProgress
|
||||
progress={task.progress}
|
||||
numberOfSubTasks={task.sub_tasks?.length || 0}
|
||||
/>
|
||||
<TaskProgress progress={task.progress} numberOfSubTasks={task.sub_tasks?.length || 0} />
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
@@ -245,19 +256,24 @@ interface LabelsColumnProps {
|
||||
visibleColumns: any[];
|
||||
}
|
||||
|
||||
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
|
||||
const labelsStyle = {
|
||||
width,
|
||||
flexShrink: 0
|
||||
};
|
||||
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(
|
||||
({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
|
||||
const labelsStyle = {
|
||||
width,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 flex-wrap min-w-0 px-2 border-r border-gray-200 dark:border-gray-700" style={labelsStyle}>
|
||||
<TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
|
||||
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 flex-wrap min-w-0 px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={labelsStyle}
|
||||
>
|
||||
<TaskLabelsCell labels={task.labels} isDarkMode={isDarkMode} />
|
||||
<LabelsSelector task={labelsAdapter} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LabelsColumn.displayName = 'LabelsColumn';
|
||||
|
||||
@@ -268,15 +284,16 @@ interface PhaseColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const PhaseColumn: React.FC<PhaseColumnProps> = memo(({ width, task, projectId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<TaskPhaseDropdown
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
export const PhaseColumn: React.FC<PhaseColumnProps> = memo(
|
||||
({ width, task, projectId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskPhaseDropdown task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
PhaseColumn.displayName = 'PhaseColumn';
|
||||
|
||||
@@ -286,11 +303,16 @@ interface TimeTrackingColumnProps {
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const TimeTrackingColumn: React.FC<TimeTrackingColumnProps> = memo(({ width, taskId, isDarkMode }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<TaskTimeTracking taskId={taskId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
));
|
||||
export const TimeTrackingColumn: React.FC<TimeTrackingColumnProps> = memo(
|
||||
({ width, taskId, isDarkMode }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<TaskTimeTracking taskId={taskId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
TimeTrackingColumn.displayName = 'TimeTrackingColumn';
|
||||
|
||||
@@ -302,11 +324,11 @@ interface EstimationColumnProps {
|
||||
export const EstimationColumn: React.FC<EstimationColumnProps> = memo(({ width, task }) => {
|
||||
const estimationDisplay = (() => {
|
||||
const estimatedHours = task.timeTracking?.estimated;
|
||||
|
||||
|
||||
if (estimatedHours && estimatedHours > 0) {
|
||||
const hours = Math.floor(estimatedHours);
|
||||
const minutes = Math.round((estimatedHours - hours) * 60);
|
||||
|
||||
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (hours > 0) {
|
||||
@@ -315,20 +337,19 @@ export const EstimationColumn: React.FC<EstimationColumnProps> = memo(({ width,
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{estimationDisplay ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{estimationDisplay}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{estimationDisplay}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
-
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -342,17 +363,24 @@ interface DateColumnProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const DateColumn: React.FC<DateColumnProps> = memo(({ width, formattedDate, placeholder = '-' }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
{formattedDate ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
export const DateColumn: React.FC<DateColumnProps> = memo(
|
||||
({ width, formattedDate, placeholder = '-' }) => (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{formattedDate ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
DateColumn.displayName = 'DateColumn';
|
||||
|
||||
@@ -362,7 +390,10 @@ interface ReporterColumnProps {
|
||||
}
|
||||
|
||||
export const ReporterColumn: React.FC<ReporterColumnProps> = memo(({ width, reporter }) => (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{reporter ? (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">{reporter}</span>
|
||||
) : (
|
||||
@@ -380,18 +411,23 @@ interface CustomColumnProps {
|
||||
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
||||
}
|
||||
|
||||
export const CustomColumn: React.FC<CustomColumnProps> = memo(({ width, column, task, updateTaskCustomColumnValue }) => {
|
||||
if (!updateTaskCustomColumnValue) return null;
|
||||
export const CustomColumn: React.FC<CustomColumnProps> = memo(
|
||||
({ width, column, task, updateTaskCustomColumnValue }) => {
|
||||
if (!updateTaskCustomColumnValue) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700" style={{ width }}>
|
||||
<CustomColumnCell
|
||||
column={column}
|
||||
task={task}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center px-2 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
<CustomColumnCell
|
||||
column={column}
|
||||
task={task}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CustomColumn.displayName = 'CustomColumn';
|
||||
CustomColumn.displayName = 'CustomColumn';
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@/shared/antd-imports';
|
||||
import {
|
||||
RightOutlined,
|
||||
DoubleRightOutlined,
|
||||
ArrowsAltOutlined,
|
||||
CommentOutlined,
|
||||
EyeOutlined,
|
||||
PaperClipOutlined,
|
||||
MinusCircleOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { Input, Tooltip } from '@/shared/antd-imports';
|
||||
import type { InputRef } from '@/shared/antd-imports';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
toggleTaskExpansion,
|
||||
fetchSubTasks,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
@@ -27,275 +39,312 @@ interface TitleColumnProps {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
width,
|
||||
task,
|
||||
projectId,
|
||||
isSubtask,
|
||||
taskDisplayName,
|
||||
editTaskName,
|
||||
taskName,
|
||||
onEditTaskName,
|
||||
onTaskNameChange,
|
||||
onTaskNameSave,
|
||||
depth = 0
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket, connected } = useSocket();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
export const TitleColumn: React.FC<TitleColumnProps> = memo(
|
||||
({
|
||||
width,
|
||||
task,
|
||||
projectId,
|
||||
isSubtask,
|
||||
taskDisplayName,
|
||||
editTaskName,
|
||||
taskName,
|
||||
onEditTaskName,
|
||||
onTaskNameChange,
|
||||
onTaskNameSave,
|
||||
depth = 0,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket, connected } = useSocket();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle task expansion toggle
|
||||
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Always try to fetch subtasks when expanding, regardless of count
|
||||
if (!task.show_sub_tasks && (!task.sub_tasks || task.sub_tasks.length === 0)) {
|
||||
dispatch(fetchSubTasks({ taskId: task.id, projectId }));
|
||||
}
|
||||
|
||||
// Toggle expansion state
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
}, [dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]);
|
||||
// Context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Handle task name save
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
const newTaskName = inputRef.current?.input?.value || taskName;
|
||||
if (newTaskName?.trim() !== '' && connected && newTaskName.trim() !== (task.title || task.name || '').trim()) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
name: newTaskName.trim(),
|
||||
parent_task: task.parent_task_id,
|
||||
})
|
||||
);
|
||||
}
|
||||
onEditTaskName(false);
|
||||
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]);
|
||||
// Handle task expansion toggle
|
||||
const handleToggleExpansion = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Use clientX and clientY directly for fixed positioning
|
||||
setContextMenuPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
});
|
||||
setContextMenuVisible(true);
|
||||
}, []);
|
||||
// Always try to fetch subtasks when expanding, regardless of count
|
||||
if (!task.show_sub_tasks && (!task.sub_tasks || task.sub_tasks.length === 0)) {
|
||||
dispatch(fetchSubTasks({ taskId: task.id, projectId }));
|
||||
}
|
||||
|
||||
// Handle context menu close
|
||||
const handleContextMenuClose = useCallback(() => {
|
||||
setContextMenuVisible(false);
|
||||
}, []);
|
||||
// Toggle expansion state
|
||||
dispatch(toggleTaskExpansion(task.id));
|
||||
},
|
||||
[dispatch, task.id, task.sub_tasks, task.show_sub_tasks, projectId]
|
||||
);
|
||||
|
||||
// Handle click outside for task name editing
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
handleTaskNameSave();
|
||||
// Handle task name save
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
const newTaskName = inputRef.current?.input?.value || taskName;
|
||||
if (
|
||||
newTaskName?.trim() !== '' &&
|
||||
connected &&
|
||||
newTaskName.trim() !== (task.title || task.name || '').trim()
|
||||
) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
name: newTaskName.trim(),
|
||||
parent_task: task.parent_task_id,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
onEditTaskName(false);
|
||||
}, [
|
||||
taskName,
|
||||
connected,
|
||||
socket,
|
||||
task.id,
|
||||
task.parent_task_id,
|
||||
task.title,
|
||||
task.name,
|
||||
onEditTaskName,
|
||||
]);
|
||||
|
||||
if (editTaskName) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editTaskName, handleTaskNameSave]);
|
||||
// Use clientX and clientY directly for fixed positioning
|
||||
setContextMenuPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
setContextMenuVisible(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between group pl-1 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{editTaskName ? (
|
||||
/* Full cell input when editing */
|
||||
<div className="flex-1" style={{ height: '38px' }} ref={wrapperRef}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
variant="borderless"
|
||||
value={taskName}
|
||||
onChange={(e) => onTaskNameChange(e.target.value)}
|
||||
autoFocus
|
||||
onPressEnter={handleTaskNameSave}
|
||||
onBlur={handleTaskNameSave}
|
||||
className="text-sm"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '38px',
|
||||
margin: '0',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #1677ff',
|
||||
backgroundColor: 'rgba(22, 119, 255, 0.02)',
|
||||
borderRadius: '3px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
boxShadow: '0 0 0 2px rgba(22, 119, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal layout when not editing */
|
||||
<>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
{/* Indentation for subtasks - reduced spacing for level 1 */}
|
||||
{isSubtask && <div className="w-2 flex-shrink-0" />}
|
||||
|
||||
{/* Additional indentation for deeper levels - increased spacing for level 2+ */}
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div key={i} className="w-6 flex-shrink-0" />
|
||||
))}
|
||||
|
||||
{/* Expand/Collapse button - show for any task that can have sub-tasks */}
|
||||
{depth < 2 && ( // Only show if not at maximum depth (can still have children)
|
||||
<button
|
||||
onClick={handleToggleExpansion}
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out flex-shrink-0 ${
|
||||
task.sub_tasks_count != null && task.sub_tasks_count > 0
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transformOrigin: 'center'
|
||||
}}
|
||||
// Handle context menu close
|
||||
const handleContextMenuClose = useCallback(() => {
|
||||
setContextMenuVisible(false);
|
||||
}, []);
|
||||
|
||||
// Handle click outside for task name editing
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
handleTaskNameSave();
|
||||
}
|
||||
};
|
||||
|
||||
if (editTaskName) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editTaskName, handleTaskNameSave]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between group pl-1 border-r border-gray-200 dark:border-gray-700"
|
||||
style={{ width }}
|
||||
>
|
||||
{editTaskName ? (
|
||||
/* Full cell input when editing */
|
||||
<div className="flex-1" style={{ height: '38px' }} ref={wrapperRef}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
variant="borderless"
|
||||
value={taskName}
|
||||
onChange={e => onTaskNameChange(e.target.value)}
|
||||
autoFocus
|
||||
onPressEnter={handleTaskNameSave}
|
||||
onBlur={handleTaskNameSave}
|
||||
className="text-sm"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '38px',
|
||||
margin: '0',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #1677ff',
|
||||
backgroundColor: 'rgba(22, 119, 255, 0.02)',
|
||||
borderRadius: '3px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '22px',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
boxShadow: '0 0 0 2px rgba(22, 119, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal layout when not editing */
|
||||
<>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
{/* Indentation for subtasks - reduced spacing for level 1 */}
|
||||
{isSubtask && <div className="w-2 flex-shrink-0" />}
|
||||
|
||||
{/* Additional indentation for deeper levels - increased spacing for level 2+ */}
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div key={i} className="w-6 flex-shrink-0" />
|
||||
))}
|
||||
|
||||
{/* Expand/Collapse button - show for any task that can have sub-tasks */}
|
||||
{depth < 2 && ( // Only show if not at maximum depth (can still have children)
|
||||
<button
|
||||
onClick={handleToggleExpansion}
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out flex-shrink-0 ${
|
||||
task.sub_tasks_count != null && task.sub_tasks_count > 0
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
||||
<div
|
||||
className="transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Additional indentation for subtasks after the expand button space - reduced for level 1 */}
|
||||
{isSubtask && <div className="w-1 flex-shrink-0" />}
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Task name with dynamic width */}
|
||||
<div className="flex-1 min-w-0" ref={wrapperRef}>
|
||||
<span
|
||||
className="text-sm text-gray-700 dark:text-gray-300 truncate cursor-text block"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onEditTaskName(true);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
title={taskDisplayName}
|
||||
>
|
||||
{taskDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Additional indentation for subtasks after the expand button space - reduced for level 1 */}
|
||||
{isSubtask && <div className="w-1 flex-shrink-0" />}
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Task name with dynamic width */}
|
||||
<div className="flex-1 min-w-0" ref={wrapperRef}>
|
||||
<span
|
||||
className="text-sm text-gray-700 dark:text-gray-300 truncate cursor-text block"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onEditTaskName(true);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
title={taskDisplayName}
|
||||
>
|
||||
{taskDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Indicators container - flex-shrink-0 to prevent compression */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Subtask count indicator - show for any task that can have sub-tasks */}
|
||||
{depth < 2 && task.sub_tasks_count != null && task.sub_tasks_count > 0 && (
|
||||
<Tooltip title={t(`indicators.tooltips.subtasks${task.sub_tasks_count === 1 ? '' : '_plural'}`, { count: task.sub_tasks_count })}>
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 rounded text-xs">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">
|
||||
{task.sub_tasks_count}
|
||||
</span>
|
||||
<DoubleRightOutlined className="text-blue-600 dark:text-blue-400" style={{ fontSize: 10 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Task indicators - compact layout */}
|
||||
{task.comments_count != null && task.comments_count !== 0 && (
|
||||
<Tooltip title={t(`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`, { count: task.comments_count })}>
|
||||
<CommentOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Indicators container - flex-shrink-0 to prevent compression */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Subtask count indicator - show for any task that can have sub-tasks */}
|
||||
{depth < 2 && task.sub_tasks_count != null && task.sub_tasks_count > 0 && (
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.subtasks${task.sub_tasks_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.sub_tasks_count }
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 rounded text-xs">
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">
|
||||
{task.sub_tasks_count}
|
||||
</span>
|
||||
<DoubleRightOutlined
|
||||
className="text-blue-600 dark:text-blue-400"
|
||||
style={{ fontSize: 10 }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{task.has_subscribers && (
|
||||
<Tooltip title={t('indicators.tooltips.subscribers')}>
|
||||
<EyeOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Task indicators - compact layout */}
|
||||
{task.comments_count != null && task.comments_count !== 0 && (
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.comments${task.comments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.comments_count }
|
||||
)}
|
||||
>
|
||||
<CommentOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{task.attachments_count != null && task.attachments_count !== 0 && (
|
||||
<Tooltip title={t(`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`, { count: task.attachments_count })}>
|
||||
<PaperClipOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.has_subscribers && (
|
||||
<Tooltip title={t('indicators.tooltips.subscribers')}>
|
||||
<EyeOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{task.has_dependencies && (
|
||||
<Tooltip title={t('indicators.tooltips.dependencies')}>
|
||||
<MinusCircleOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.attachments_count != null && task.attachments_count !== 0 && (
|
||||
<Tooltip
|
||||
title={t(
|
||||
`indicators.tooltips.attachments${task.attachments_count === 1 ? '' : '_plural'}`,
|
||||
{ count: task.attachments_count }
|
||||
)}
|
||||
>
|
||||
<PaperClipOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{task.schedule_id && (
|
||||
<Tooltip title={t('indicators.tooltips.recurring')}>
|
||||
<RetweetOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.has_dependencies && (
|
||||
<Tooltip title={t('indicators.tooltips.dependencies')}>
|
||||
<MinusCircleOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{task.schedule_id && (
|
||||
<Tooltip title={t('indicators.tooltips.recurring')}>
|
||||
<RetweetOutlined
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ fontSize: 12 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-all duration-200 ml-2 mr-2 px-3 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer rounded-md shadow-sm hover:shadow-md flex items-center gap-1 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(setSelectedTaskId(task.id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}}
|
||||
>
|
||||
<ArrowsAltOutlined />
|
||||
{t('openButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuVisible && createPortal(
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
position={contextMenuPosition}
|
||||
onClose={handleContextMenuClose}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TitleColumn.displayName = 'TitleColumn';
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-all duration-200 ml-2 mr-2 px-3 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer rounded-md shadow-sm hover:shadow-md flex items-center gap-1 flex-shrink-0"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
dispatch(setSelectedTaskId(task.id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}}
|
||||
>
|
||||
<ArrowsAltOutlined />
|
||||
{t('openButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuVisible &&
|
||||
createPortal(
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
position={contextMenuPosition}
|
||||
onClose={handleContextMenuClose}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TitleColumn.displayName = 'TitleColumn';
|
||||
|
||||
@@ -15,7 +15,14 @@ export type ColumnStyle = {
|
||||
export const BASE_COLUMNS = [
|
||||
{ id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' },
|
||||
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' },
|
||||
{ id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' },
|
||||
{
|
||||
id: 'taskKey',
|
||||
label: 'keyColumn',
|
||||
width: '100px',
|
||||
key: COLUMN_KEYS.KEY,
|
||||
minWidth: '100px',
|
||||
maxWidth: '150px',
|
||||
},
|
||||
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||
{ id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION },
|
||||
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
||||
@@ -24,13 +31,23 @@ export const BASE_COLUMNS = [
|
||||
{ id: 'labels', label: 'labelsColumn', width: '250px', key: COLUMN_KEYS.LABELS },
|
||||
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
|
||||
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||
{
|
||||
id: 'timeTracking',
|
||||
label: 'timeTrackingColumn',
|
||||
width: '120px',
|
||||
key: COLUMN_KEYS.TIME_TRACKING,
|
||||
},
|
||||
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||
{ id: 'startDate', label: 'startDateColumn', width: '140px', key: COLUMN_KEYS.START_DATE },
|
||||
{ id: 'dueDate', label: 'dueDateColumn', width: '140px', key: COLUMN_KEYS.DUE_DATE },
|
||||
{ id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME },
|
||||
{ id: 'completedDate', label: 'completedDateColumn', width: '140px', key: COLUMN_KEYS.COMPLETED_DATE },
|
||||
{
|
||||
id: 'completedDate',
|
||||
label: 'completedDateColumn',
|
||||
width: '140px',
|
||||
key: COLUMN_KEYS.COMPLETED_DATE,
|
||||
},
|
||||
{ id: 'createdDate', label: 'createdDateColumn', width: '140px', key: COLUMN_KEYS.CREATED_DATE },
|
||||
{ id: 'lastUpdated', label: 'lastUpdatedColumn', width: '140px', key: COLUMN_KEYS.LAST_UPDATED },
|
||||
{ id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER },
|
||||
];
|
||||
];
|
||||
|
||||
@@ -67,128 +67,141 @@ export const useBulkActions = () => {
|
||||
dispatch(clearSelection());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleBulkStatusChange = useCallback(async (statusId: string, selectedTaskIds: string[]) => {
|
||||
if (!statusId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('status', true);
|
||||
const handleBulkStatusChange = useCallback(
|
||||
async (statusId: string, selectedTaskIds: string[]) => {
|
||||
if (!statusId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
// Check task dependencies before proceeding
|
||||
for (const taskId of selectedTaskIds) {
|
||||
const canContinue = await checkTaskDependencyStatus(taskId, statusId);
|
||||
if (!canContinue) {
|
||||
if (selectedTaskIds.length > 1) {
|
||||
alertService.warning(
|
||||
'Incomplete Dependencies!',
|
||||
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||
);
|
||||
} else {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
try {
|
||||
updateLoadingState('status', true);
|
||||
|
||||
// Check task dependencies before proceeding
|
||||
for (const taskId of selectedTaskIds) {
|
||||
const canContinue = await checkTaskDependencyStatus(taskId, statusId);
|
||||
if (!canContinue) {
|
||||
if (selectedTaskIds.length > 1) {
|
||||
alertService.warning(
|
||||
'Incomplete Dependencies!',
|
||||
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||
);
|
||||
} else {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body: IBulkTasksStatusChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
status_id: statusId,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing status:', error);
|
||||
} finally {
|
||||
updateLoadingState('status', false);
|
||||
}
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const body: IBulkTasksStatusChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
status_id: statusId,
|
||||
};
|
||||
const handleBulkPriorityChange = useCallback(
|
||||
async (priorityId: string, selectedTaskIds: string[]) => {
|
||||
if (!priorityId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
try {
|
||||
updateLoadingState('priority', true);
|
||||
|
||||
const body: IBulkTasksPriorityChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
priority_id: priorityId,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing priority:', error);
|
||||
} finally {
|
||||
updateLoadingState('priority', false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing status:', error);
|
||||
} finally {
|
||||
updateLoadingState('status', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkPriorityChange = useCallback(async (priorityId: string, selectedTaskIds: string[]) => {
|
||||
if (!priorityId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('priority', true);
|
||||
|
||||
const body: IBulkTasksPriorityChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
priority_id: priorityId,
|
||||
};
|
||||
const handleBulkPhaseChange = useCallback(
|
||||
async (phaseId: string, selectedTaskIds: string[]) => {
|
||||
if (!phaseId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
try {
|
||||
updateLoadingState('phase', true);
|
||||
|
||||
const body: IBulkTasksPhaseChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
phase_id: phaseId,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing phase:', error);
|
||||
} finally {
|
||||
updateLoadingState('phase', false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing priority:', error);
|
||||
} finally {
|
||||
updateLoadingState('priority', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkPhaseChange = useCallback(async (phaseId: string, selectedTaskIds: string[]) => {
|
||||
if (!phaseId || !projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('phase', true);
|
||||
|
||||
const body: IBulkTasksPhaseChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
phase_id: phaseId,
|
||||
};
|
||||
const handleBulkAssignToMe = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
try {
|
||||
updateLoadingState('assignToMe', true);
|
||||
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning to me:', error);
|
||||
} finally {
|
||||
updateLoadingState('assignToMe', false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing phase:', error);
|
||||
} finally {
|
||||
updateLoadingState('phase', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkAssignToMe = useCallback(async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('assignToMe', true);
|
||||
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
const handleBulkAssignMembers = useCallback(
|
||||
async (memberIds: string[], selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning to me:', error);
|
||||
} finally {
|
||||
updateLoadingState('assignToMe', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
try {
|
||||
updateLoadingState('assignMembers', true);
|
||||
|
||||
const handleBulkAssignMembers = useCallback(async (memberIds: string[], selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('assignMembers', true);
|
||||
|
||||
// Convert memberIds to member objects - this would need to be handled by the component
|
||||
// Convert memberIds to member objects - this would need to be handled by the component
|
||||
// For now, we'll just pass the IDs and let the API handle it
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
@@ -201,142 +214,162 @@ export const useBulkActions = () => {
|
||||
})) as ITaskAssignee[],
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('assignMembers', false);
|
||||
}
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkAddLabels = useCallback(
|
||||
async (labelIds: string[], selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('labels', true);
|
||||
|
||||
// Convert labelIds to label objects - this would need to be handled by the component
|
||||
// For now, we'll just pass the IDs and let the API handle it
|
||||
const body: IBulkTasksLabelsRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
labels: labelIds.map(id => ({ id, name: '', color: '' })) as ITaskLabel[],
|
||||
text: null,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchLabels()); // Refetch labels in case new ones were created
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating labels:', error);
|
||||
} finally {
|
||||
updateLoadingState('labels', false);
|
||||
}
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkArchive = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('archive', true);
|
||||
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('archive', false);
|
||||
}
|
||||
},
|
||||
[projectId, archived, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('delete', true);
|
||||
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('delete', false);
|
||||
}
|
||||
},
|
||||
[projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkDuplicate = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('duplicate', true);
|
||||
// TODO: Implement bulk duplicate API call when available
|
||||
console.log('Bulk duplicate:', selectedTaskIds);
|
||||
// For now, just clear selection and refetch
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('duplicate', false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('assignMembers', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkAddLabels = useCallback(async (labelIds: string[], selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('labels', true);
|
||||
|
||||
// Convert labelIds to label objects - this would need to be handled by the component
|
||||
// For now, we'll just pass the IDs and let the API handle it
|
||||
const body: IBulkTasksLabelsRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
labels: labelIds.map(id => ({ id, name: '', color: '' })) as ITaskLabel[],
|
||||
text: null,
|
||||
};
|
||||
const handleBulkExport = useCallback(
|
||||
async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchLabels()); // Refetch labels in case new ones were created
|
||||
refetchTasks();
|
||||
try {
|
||||
updateLoadingState('export', true);
|
||||
// TODO: Implement bulk export API call when available
|
||||
console.log('Bulk export:', selectedTaskIds);
|
||||
} catch (error) {
|
||||
logger.error('Error exporting tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('export', false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating labels:', error);
|
||||
} finally {
|
||||
updateLoadingState('labels', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, updateLoadingState]
|
||||
);
|
||||
|
||||
const handleBulkArchive = useCallback(async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('archive', true);
|
||||
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
const handleBulkSetDueDate = useCallback(
|
||||
async (date: string, selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||
try {
|
||||
updateLoadingState('dueDate', true);
|
||||
// TODO: Implement bulk set due date API call when available
|
||||
console.log('Bulk set due date:', date, selectedTaskIds);
|
||||
// For now, just clear selection and refetch
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
} catch (error) {
|
||||
logger.error('Error setting due date:', error);
|
||||
} finally {
|
||||
updateLoadingState('dueDate', false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('archive', false);
|
||||
}
|
||||
}, [projectId, archived, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
|
||||
const handleBulkDelete = useCallback(async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('delete', true);
|
||||
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('delete', false);
|
||||
}
|
||||
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||
|
||||
const handleBulkDuplicate = useCallback(async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('duplicate', true);
|
||||
// TODO: Implement bulk duplicate API call when available
|
||||
console.log('Bulk duplicate:', selectedTaskIds);
|
||||
// For now, just clear selection and refetch
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('duplicate', false);
|
||||
}
|
||||
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
|
||||
|
||||
const handleBulkExport = useCallback(async (selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('export', true);
|
||||
// TODO: Implement bulk export API call when available
|
||||
console.log('Bulk export:', selectedTaskIds);
|
||||
} catch (error) {
|
||||
logger.error('Error exporting tasks:', error);
|
||||
} finally {
|
||||
updateLoadingState('export', false);
|
||||
}
|
||||
}, [projectId, updateLoadingState]);
|
||||
|
||||
const handleBulkSetDueDate = useCallback(async (date: string, selectedTaskIds: string[]) => {
|
||||
if (!projectId || !selectedTaskIds.length) return;
|
||||
|
||||
try {
|
||||
updateLoadingState('dueDate', true);
|
||||
// TODO: Implement bulk set due date API call when available
|
||||
console.log('Bulk set due date:', date, selectedTaskIds);
|
||||
// For now, just clear selection and refetch
|
||||
dispatch(clearSelection());
|
||||
refetchTasks();
|
||||
} catch (error) {
|
||||
logger.error('Error setting due date:', error);
|
||||
} finally {
|
||||
updateLoadingState('dueDate', false);
|
||||
}
|
||||
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
|
||||
},
|
||||
[projectId, dispatch, refetchTasks, updateLoadingState]
|
||||
);
|
||||
|
||||
return {
|
||||
handleClearSelection,
|
||||
@@ -353,4 +386,4 @@ export const useBulkActions = () => {
|
||||
handleBulkSetDueDate,
|
||||
loadingStates,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,17 +39,17 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
|
||||
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
||||
const taskUpdates: any[] = [];
|
||||
|
||||
|
||||
// Create a copy of all groups and perform the move operation
|
||||
const updatedGroups = groups.map(group => ({
|
||||
...group,
|
||||
taskIds: [...group.taskIds]
|
||||
taskIds: [...group.taskIds],
|
||||
}));
|
||||
|
||||
|
||||
// Find the source and target groups in our copy
|
||||
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
|
||||
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
|
||||
|
||||
|
||||
if (sourceGroup.id === targetGroup.id) {
|
||||
// Same group - reorder within the group
|
||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
||||
@@ -62,20 +62,20 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
// Remove from source group
|
||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
||||
|
||||
|
||||
// Add to target group
|
||||
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
|
||||
}
|
||||
|
||||
|
||||
// Now assign sequential sort orders to ALL tasks across ALL groups
|
||||
let currentSortOrder = 0;
|
||||
updatedGroups.forEach(group => {
|
||||
group.taskIds.forEach(id => {
|
||||
const update: any = {
|
||||
task_id: id,
|
||||
sort_order: currentSortOrder
|
||||
sort_order: currentSortOrder,
|
||||
};
|
||||
|
||||
|
||||
// Add group-specific fields for the moved task if it changed groups
|
||||
if (id === taskId && sourceGroup.id !== targetGroup.id) {
|
||||
if (currentGrouping === 'status') {
|
||||
@@ -86,7 +86,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
update.phase_id = targetGroup.id;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
taskUpdates.push(update);
|
||||
currentSortOrder++;
|
||||
});
|
||||
@@ -120,18 +120,24 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
});
|
||||
} else if (currentGrouping === 'priority') {
|
||||
// Emit priority change event
|
||||
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
|
||||
task_id: taskId,
|
||||
priority_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
}));
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: taskId,
|
||||
priority_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
})
|
||||
);
|
||||
} else if (currentGrouping === 'status') {
|
||||
// Emit status change event
|
||||
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
|
||||
task_id: taskId,
|
||||
status_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
}));
|
||||
socket.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: taskId,
|
||||
status_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -208,7 +214,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
// Check if we're dropping on a task, group, or empty group
|
||||
const overTask = allTasks.find(task => task.id === overId);
|
||||
const overGroup = groups.find(group => group.id === overId);
|
||||
|
||||
|
||||
// Check if dropping on empty group drop zone
|
||||
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
|
||||
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null;
|
||||
@@ -290,4 +296,4 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,14 +24,21 @@ export const useTaskRowActions = ({
|
||||
const { socket, connected } = useSocket();
|
||||
|
||||
// Handle checkbox change
|
||||
const handleCheckboxChange = useCallback((e: any) => {
|
||||
e.stopPropagation(); // Prevent row click when clicking checkbox
|
||||
dispatch(toggleTaskSelection(taskId));
|
||||
}, [dispatch, taskId]);
|
||||
const handleCheckboxChange = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation(); // Prevent row click when clicking checkbox
|
||||
dispatch(toggleTaskSelection(taskId));
|
||||
},
|
||||
[dispatch, taskId]
|
||||
);
|
||||
|
||||
// Handle task name save
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
if (taskName?.trim() !== '' && connected && taskName.trim() !== (task.title || task.name || '').trim()) {
|
||||
if (
|
||||
taskName?.trim() !== '' &&
|
||||
connected &&
|
||||
taskName.trim() !== (task.title || task.name || '').trim()
|
||||
) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
@@ -42,7 +49,16 @@ export const useTaskRowActions = ({
|
||||
);
|
||||
}
|
||||
setEditTaskName(false);
|
||||
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, setEditTaskName]);
|
||||
}, [
|
||||
taskName,
|
||||
connected,
|
||||
socket,
|
||||
task.id,
|
||||
task.parent_task_id,
|
||||
task.title,
|
||||
task.name,
|
||||
setEditTaskName,
|
||||
]);
|
||||
|
||||
// Handle task name edit start
|
||||
const handleTaskNameEdit = useCallback(() => {
|
||||
@@ -60,4 +76,4 @@ export const useTaskRowActions = ({
|
||||
handleTaskNameEdit,
|
||||
handleTaskNameChange,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ interface UseTaskRowColumnsProps {
|
||||
isCustom?: boolean;
|
||||
}>;
|
||||
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
||||
|
||||
|
||||
// From useTaskRowState
|
||||
taskDisplayName: string;
|
||||
convertedTask: any;
|
||||
@@ -49,16 +49,16 @@ interface UseTaskRowColumnsProps {
|
||||
taskName: string;
|
||||
setEditTaskName: (editing: boolean) => void;
|
||||
setTaskName: (name: string) => void;
|
||||
|
||||
|
||||
// From useTaskRowActions
|
||||
handleCheckboxChange: (e: any) => void;
|
||||
handleTaskNameSave: () => void;
|
||||
handleTaskNameEdit: () => void;
|
||||
|
||||
|
||||
// Drag and drop
|
||||
attributes: any;
|
||||
listeners: any;
|
||||
|
||||
|
||||
// Depth for nested subtasks
|
||||
depth?: number;
|
||||
}
|
||||
@@ -89,237 +89,185 @@ export const useTaskRowColumns = ({
|
||||
listeners,
|
||||
depth = 0,
|
||||
}: UseTaskRowColumnsProps) => {
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
<DragHandleColumn
|
||||
width={width}
|
||||
isSubtask={isSubtask}
|
||||
attributes={attributes}
|
||||
listeners={listeners}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxColumn
|
||||
width={width}
|
||||
isSelected={isSelected}
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'taskKey':
|
||||
return (
|
||||
<TaskKeyColumn
|
||||
width={width}
|
||||
taskKey={task.task_key || ''}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'title':
|
||||
return (
|
||||
<TitleColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isSubtask={isSubtask}
|
||||
taskDisplayName={taskDisplayName}
|
||||
editTaskName={editTaskName}
|
||||
taskName={taskName}
|
||||
onEditTaskName={setEditTaskName}
|
||||
onTaskNameChange={setTaskName}
|
||||
onTaskNameSave={handleTaskNameSave}
|
||||
depth={depth}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'description':
|
||||
return (
|
||||
<DescriptionColumn
|
||||
width={width}
|
||||
description={task.description || ''}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<StatusColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'assignees':
|
||||
return (
|
||||
<AssigneesColumn
|
||||
width={width}
|
||||
task={task}
|
||||
convertedTask={convertedTask}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<PriorityColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<DatePickerColumn
|
||||
width={width}
|
||||
task={task}
|
||||
field="dueDate"
|
||||
formattedDate={formattedDates.due}
|
||||
dateValue={dateValues.due}
|
||||
isDarkMode={isDarkMode}
|
||||
activeDatePicker={activeDatePicker}
|
||||
onActiveDatePickerChange={setActiveDatePicker}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'startDate':
|
||||
return (
|
||||
<DatePickerColumn
|
||||
width={width}
|
||||
task={task}
|
||||
field="startDate"
|
||||
formattedDate={formattedDates.start}
|
||||
dateValue={dateValues.start}
|
||||
isDarkMode={isDarkMode}
|
||||
activeDatePicker={activeDatePicker}
|
||||
onActiveDatePickerChange={setActiveDatePicker}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<ProgressColumn
|
||||
width={width}
|
||||
task={task}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'labels':
|
||||
return (
|
||||
<LabelsColumn
|
||||
width={width}
|
||||
task={task}
|
||||
labelsAdapter={labelsAdapter}
|
||||
isDarkMode={isDarkMode}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'phase':
|
||||
return (
|
||||
<PhaseColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'timeTracking':
|
||||
return (
|
||||
<TimeTrackingColumn
|
||||
width={width}
|
||||
taskId={task.id || ''}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'estimation':
|
||||
return (
|
||||
<EstimationColumn
|
||||
width={width}
|
||||
task={task}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'completedDate':
|
||||
return (
|
||||
<DateColumn
|
||||
width={width}
|
||||
formattedDate={formattedDates.completed}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'createdDate':
|
||||
return (
|
||||
<DateColumn
|
||||
width={width}
|
||||
formattedDate={formattedDates.created}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'lastUpdated':
|
||||
return (
|
||||
<DateColumn
|
||||
width={width}
|
||||
formattedDate={formattedDates.updated}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'reporter':
|
||||
return (
|
||||
<ReporterColumn
|
||||
width={width}
|
||||
reporter={task.reporter || ''}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// Handle custom columns
|
||||
const column = visibleColumns.find(col => col.id === columnId);
|
||||
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
|
||||
const renderColumn = useCallback(
|
||||
(columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
<CustomColumn
|
||||
<DragHandleColumn
|
||||
width={width}
|
||||
column={column}
|
||||
task={task}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
isSubtask={isSubtask}
|
||||
attributes={attributes}
|
||||
listeners={listeners}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
task,
|
||||
projectId,
|
||||
isSubtask,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
visibleColumns,
|
||||
updateTaskCustomColumnValue,
|
||||
taskDisplayName,
|
||||
convertedTask,
|
||||
formattedDates,
|
||||
dateValues,
|
||||
labelsAdapter,
|
||||
activeDatePicker,
|
||||
setActiveDatePicker,
|
||||
editTaskName,
|
||||
taskName,
|
||||
setEditTaskName,
|
||||
setTaskName,
|
||||
handleCheckboxChange,
|
||||
handleTaskNameSave,
|
||||
handleTaskNameEdit,
|
||||
attributes,
|
||||
listeners,
|
||||
]);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxColumn
|
||||
width={width}
|
||||
isSelected={isSelected}
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'taskKey':
|
||||
return <TaskKeyColumn width={width} taskKey={task.task_key || ''} />;
|
||||
|
||||
case 'title':
|
||||
return (
|
||||
<TitleColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isSubtask={isSubtask}
|
||||
taskDisplayName={taskDisplayName}
|
||||
editTaskName={editTaskName}
|
||||
taskName={taskName}
|
||||
onEditTaskName={setEditTaskName}
|
||||
onTaskNameChange={setTaskName}
|
||||
onTaskNameSave={handleTaskNameSave}
|
||||
depth={depth}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'description':
|
||||
return <DescriptionColumn width={width} description={task.description || ''} />;
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<StatusColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
);
|
||||
|
||||
case 'assignees':
|
||||
return (
|
||||
<AssigneesColumn
|
||||
width={width}
|
||||
task={task}
|
||||
convertedTask={convertedTask}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<PriorityColumn
|
||||
width={width}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<DatePickerColumn
|
||||
width={width}
|
||||
task={task}
|
||||
field="dueDate"
|
||||
formattedDate={formattedDates.due}
|
||||
dateValue={dateValues.due}
|
||||
isDarkMode={isDarkMode}
|
||||
activeDatePicker={activeDatePicker}
|
||||
onActiveDatePickerChange={setActiveDatePicker}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'startDate':
|
||||
return (
|
||||
<DatePickerColumn
|
||||
width={width}
|
||||
task={task}
|
||||
field="startDate"
|
||||
formattedDate={formattedDates.start}
|
||||
dateValue={dateValues.start}
|
||||
isDarkMode={isDarkMode}
|
||||
activeDatePicker={activeDatePicker}
|
||||
onActiveDatePickerChange={setActiveDatePicker}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return <ProgressColumn width={width} task={task} />;
|
||||
|
||||
case 'labels':
|
||||
return (
|
||||
<LabelsColumn
|
||||
width={width}
|
||||
task={task}
|
||||
labelsAdapter={labelsAdapter}
|
||||
isDarkMode={isDarkMode}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'phase':
|
||||
return (
|
||||
<PhaseColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
|
||||
);
|
||||
|
||||
case 'timeTracking':
|
||||
return (
|
||||
<TimeTrackingColumn width={width} taskId={task.id || ''} isDarkMode={isDarkMode} />
|
||||
);
|
||||
|
||||
case 'estimation':
|
||||
return <EstimationColumn width={width} task={task} />;
|
||||
|
||||
case 'completedDate':
|
||||
return <DateColumn width={width} formattedDate={formattedDates.completed} />;
|
||||
|
||||
case 'createdDate':
|
||||
return <DateColumn width={width} formattedDate={formattedDates.created} />;
|
||||
|
||||
case 'lastUpdated':
|
||||
return <DateColumn width={width} formattedDate={formattedDates.updated} />;
|
||||
|
||||
case 'reporter':
|
||||
return <ReporterColumn width={width} reporter={task.reporter || ''} />;
|
||||
|
||||
default:
|
||||
// Handle custom columns
|
||||
const column = visibleColumns.find(col => col.id === columnId);
|
||||
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
|
||||
return (
|
||||
<CustomColumn
|
||||
width={width}
|
||||
column={column}
|
||||
task={task}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[
|
||||
task,
|
||||
projectId,
|
||||
isSubtask,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
visibleColumns,
|
||||
updateTaskCustomColumnValue,
|
||||
taskDisplayName,
|
||||
convertedTask,
|
||||
formattedDates,
|
||||
dateValues,
|
||||
labelsAdapter,
|
||||
activeDatePicker,
|
||||
setActiveDatePicker,
|
||||
editTaskName,
|
||||
taskName,
|
||||
setEditTaskName,
|
||||
setTaskName,
|
||||
handleCheckboxChange,
|
||||
handleTaskNameSave,
|
||||
handleTaskNameEdit,
|
||||
attributes,
|
||||
listeners,
|
||||
]
|
||||
);
|
||||
|
||||
return { renderColumn };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getTaskDisplayName, formatDate } from '../components/TaskRowColumns';
|
||||
export const useTaskRowState = (task: Task) => {
|
||||
// State for tracking which date picker is open
|
||||
const [activeDatePicker, setActiveDatePicker] = useState<string | null>(null);
|
||||
|
||||
|
||||
// State for editing task name
|
||||
const [editTaskName, setEditTaskName] = useState(false);
|
||||
const [taskName, setTaskName] = useState(task.title || task.name || '');
|
||||
@@ -18,64 +18,96 @@ export const useTaskRowState = (task: Task) => {
|
||||
}, [task.title, task.name]);
|
||||
|
||||
// Memoize task display name
|
||||
const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]);
|
||||
const taskDisplayName = useMemo(
|
||||
() => getTaskDisplayName(task),
|
||||
[task.title, task.name, task.task_key]
|
||||
);
|
||||
|
||||
// Memoize converted task for AssigneeSelector to prevent recreation
|
||||
const convertedTask = useMemo(() => ({
|
||||
id: task.id,
|
||||
name: taskDisplayName,
|
||||
task_key: task.task_key || taskDisplayName,
|
||||
assignees:
|
||||
task.assignee_names?.map((assignee: InlineMember, index: number) => ({
|
||||
team_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||
id: assignee.team_member_id || `assignee-${index}`,
|
||||
project_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||
name: assignee.name || '',
|
||||
})) || [],
|
||||
parent_task_id: task.parent_task_id,
|
||||
status_id: undefined,
|
||||
project_id: undefined,
|
||||
manual_progress: undefined,
|
||||
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
|
||||
const convertedTask = useMemo(
|
||||
() => ({
|
||||
id: task.id,
|
||||
name: taskDisplayName,
|
||||
task_key: task.task_key || taskDisplayName,
|
||||
assignees:
|
||||
task.assignee_names?.map((assignee: InlineMember, index: number) => ({
|
||||
team_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||
id: assignee.team_member_id || `assignee-${index}`,
|
||||
project_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||
name: assignee.name || '',
|
||||
})) || [],
|
||||
parent_task_id: task.parent_task_id,
|
||||
status_id: undefined,
|
||||
project_id: undefined,
|
||||
manual_progress: undefined,
|
||||
}),
|
||||
[task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]
|
||||
);
|
||||
|
||||
// Memoize formatted dates
|
||||
const formattedDates = useMemo(() => ({
|
||||
due: (() => {
|
||||
const dateValue = task.dueDate || task.due_date;
|
||||
return dateValue ? formatDate(dateValue) : null;
|
||||
})(),
|
||||
start: task.startDate ? formatDate(task.startDate) : null,
|
||||
completed: task.completedAt ? formatDate(task.completedAt) : null,
|
||||
created: (task.createdAt || task.created_at) ? formatDate(task.createdAt || task.created_at) : null,
|
||||
updated: task.updatedAt ? formatDate(task.updatedAt) : null,
|
||||
}), [task.dueDate, task.due_date, task.startDate, task.completedAt, task.createdAt, task.created_at, task.updatedAt]);
|
||||
const formattedDates = useMemo(
|
||||
() => ({
|
||||
due: (() => {
|
||||
const dateValue = task.dueDate || task.due_date;
|
||||
return dateValue ? formatDate(dateValue) : null;
|
||||
})(),
|
||||
start: task.startDate ? formatDate(task.startDate) : null,
|
||||
completed: task.completedAt ? formatDate(task.completedAt) : null,
|
||||
created:
|
||||
task.createdAt || task.created_at ? formatDate(task.createdAt || task.created_at) : null,
|
||||
updated: task.updatedAt ? formatDate(task.updatedAt) : null,
|
||||
}),
|
||||
[
|
||||
task.dueDate,
|
||||
task.due_date,
|
||||
task.startDate,
|
||||
task.completedAt,
|
||||
task.createdAt,
|
||||
task.created_at,
|
||||
task.updatedAt,
|
||||
]
|
||||
);
|
||||
|
||||
// Memoize date values for DatePicker
|
||||
const dateValues = useMemo(
|
||||
() => ({
|
||||
start: task.startDate ? dayjs(task.startDate) : undefined,
|
||||
due: (task.dueDate || task.due_date) ? dayjs(task.dueDate || task.due_date) : undefined,
|
||||
due: task.dueDate || task.due_date ? dayjs(task.dueDate || task.due_date) : undefined,
|
||||
}),
|
||||
[task.startDate, task.dueDate, task.due_date]
|
||||
);
|
||||
|
||||
// Create labels adapter for LabelsSelector
|
||||
const labelsAdapter = useMemo(() => ({
|
||||
id: task.id,
|
||||
name: task.title || task.name,
|
||||
parent_task_id: task.parent_task_id,
|
||||
manual_progress: false,
|
||||
all_labels: task.all_labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color_code,
|
||||
})) || [],
|
||||
labels: task.labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color,
|
||||
})) || [],
|
||||
}), [task.id, task.title, task.name, task.parent_task_id, task.all_labels, task.labels, task.all_labels?.length, task.labels?.length]);
|
||||
const labelsAdapter = useMemo(
|
||||
() => ({
|
||||
id: task.id,
|
||||
name: task.title || task.name,
|
||||
parent_task_id: task.parent_task_id,
|
||||
manual_progress: false,
|
||||
all_labels:
|
||||
task.all_labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color_code,
|
||||
})) || [],
|
||||
labels:
|
||||
task.labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color,
|
||||
})) || [],
|
||||
}),
|
||||
[
|
||||
task.id,
|
||||
task.title,
|
||||
task.name,
|
||||
task.parent_task_id,
|
||||
task.all_labels,
|
||||
task.labels,
|
||||
task.all_labels?.length,
|
||||
task.labels?.length,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
@@ -85,7 +117,7 @@ export const useTaskRowState = (task: Task) => {
|
||||
setEditTaskName,
|
||||
taskName,
|
||||
setTaskName,
|
||||
|
||||
|
||||
// Computed values
|
||||
taskDisplayName,
|
||||
convertedTask,
|
||||
@@ -93,4 +125,4 @@ export const useTaskRowState = (task: Task) => {
|
||||
dateValues,
|
||||
labelsAdapter,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -113,7 +113,7 @@ export interface ColumnRendererProps {
|
||||
listeners: any;
|
||||
}
|
||||
|
||||
export type ColumnId =
|
||||
export type ColumnId =
|
||||
| 'dragHandle'
|
||||
| 'checkbox'
|
||||
| 'taskKey'
|
||||
@@ -242,4 +242,4 @@ export interface CustomColumnProps extends BaseColumnProps {
|
||||
export interface TaskLabelsCellProps {
|
||||
labels: Task['labels'];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@
|
||||
|
||||
/* Sortable item styling */
|
||||
.sortable-status-item {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-status-item.is-dragging {
|
||||
@@ -144,11 +146,11 @@
|
||||
width: 95% !important;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
|
||||
.dark-modal .ant-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
.status-item {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -173,7 +175,9 @@
|
||||
.status-item-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.status-item-exit {
|
||||
@@ -184,5 +188,7 @@
|
||||
.status-item-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Button, Tabs, Space, Divider, Typography, Flex, DatePicker, Select } from '@/shared/antd-imports';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Tabs,
|
||||
Space,
|
||||
Divider,
|
||||
Typography,
|
||||
Flex,
|
||||
DatePicker,
|
||||
Select,
|
||||
} from '@/shared/antd-imports';
|
||||
import { PlusOutlined, DragOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
@@ -11,7 +23,11 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import TaskDetailsForm from '@/components/task-drawer/shared/info-tab/task-details-form';
|
||||
import AssigneeSelector from '@/components/AssigneeSelector';
|
||||
import LabelsSelector from '@/components/LabelsSelector';
|
||||
import { createStatus, fetchStatuses, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import {
|
||||
createStatus,
|
||||
fetchStatuses,
|
||||
fetchStatusesCategories,
|
||||
} from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||
import { Modal as AntModal } from '@/shared/antd-imports';
|
||||
@@ -49,14 +65,9 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
const [editName, setEditName] = useState(status.name || '');
|
||||
const inputRef = useRef<any>(null);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -76,13 +87,16 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
setIsEditing(false);
|
||||
}, [status.name]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleSave, handleCancel]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleSave, handleCancel]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
@@ -127,7 +141,7 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="small"
|
||||
@@ -151,7 +165,9 @@ const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-600'}
|
||||
className={
|
||||
isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-600'
|
||||
}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
@@ -176,7 +192,7 @@ const StatusManagement: React.FC<{
|
||||
}> = ({ projectId, isDarkMode }) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
const { status: statuses } = useAppSelector(state => state.taskStatusReducer);
|
||||
const [localStatuses, setLocalStatuses] = useState(statuses);
|
||||
const [newStatusName, setNewStatusName] = useState('');
|
||||
@@ -201,9 +217,9 @@ const StatusManagement: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalStatuses((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
setLocalStatuses(items => {
|
||||
const oldIndex = items.findIndex(item => item.id === active.id);
|
||||
const newIndex = items.findIndex(item => item.id === over.id);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return items;
|
||||
|
||||
@@ -228,7 +244,7 @@ const StatusManagement: React.FC<{
|
||||
try {
|
||||
const statusCategories = await dispatch(fetchStatusesCategories()).unwrap();
|
||||
const defaultCategory = statusCategories[0]?.id;
|
||||
|
||||
|
||||
if (!defaultCategory) {
|
||||
console.error('No status categories found');
|
||||
return;
|
||||
@@ -250,35 +266,41 @@ const StatusManagement: React.FC<{
|
||||
}
|
||||
}, [newStatusName, projectId, dispatch]);
|
||||
|
||||
const handleRenameStatus = useCallback(async (id: string, name: string) => {
|
||||
try {
|
||||
const body: ITaskStatusUpdateModel = {
|
||||
name: name.trim(),
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
await statusApiService.updateNameOfStatus(id, body, projectId);
|
||||
dispatch(fetchStatuses(projectId));
|
||||
} catch (error) {
|
||||
console.error('Error renaming status:', error);
|
||||
}
|
||||
}, [projectId, dispatch]);
|
||||
const handleRenameStatus = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
try {
|
||||
const body: ITaskStatusUpdateModel = {
|
||||
name: name.trim(),
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
const handleDeleteStatus = useCallback(async (id: string) => {
|
||||
AntModal.confirm({
|
||||
title: 'Delete Status',
|
||||
content: 'Are you sure you want to delete this status? This action cannot be undone.',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const replacingStatusId = localStatuses.find(s => s.id !== id)?.id || '';
|
||||
await statusApiService.deleteStatus(id, projectId, replacingStatusId);
|
||||
dispatch(fetchStatuses(projectId));
|
||||
} catch (error) {
|
||||
console.error('Error deleting status:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [localStatuses, projectId, dispatch]);
|
||||
await statusApiService.updateNameOfStatus(id, body, projectId);
|
||||
dispatch(fetchStatuses(projectId));
|
||||
} catch (error) {
|
||||
console.error('Error renaming status:', error);
|
||||
}
|
||||
},
|
||||
[projectId, dispatch]
|
||||
);
|
||||
|
||||
const handleDeleteStatus = useCallback(
|
||||
async (id: string) => {
|
||||
AntModal.confirm({
|
||||
title: 'Delete Status',
|
||||
content: 'Are you sure you want to delete this status? This action cannot be undone.',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const replacingStatusId = localStatuses.find(s => s.id !== id)?.id || '';
|
||||
await statusApiService.deleteStatus(id, projectId, replacingStatusId);
|
||||
dispatch(fetchStatuses(projectId));
|
||||
} catch (error) {
|
||||
console.error('Error deleting status:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[localStatuses, projectId, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -296,7 +318,7 @@ const StatusManagement: React.FC<{
|
||||
<Input
|
||||
placeholder="Enter status name"
|
||||
value={newStatusName}
|
||||
onChange={(e) => setNewStatusName(e.target.value)}
|
||||
onChange={e => setNewStatusName(e.target.value)}
|
||||
onPressEnter={handleCreateStatus}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -319,16 +341,18 @@ const StatusManagement: React.FC<{
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{localStatuses.filter(status => status.id).map((status) => (
|
||||
<SortableStatusItem
|
||||
key={status.id}
|
||||
id={status.id!}
|
||||
status={status}
|
||||
onRename={handleRenameStatus}
|
||||
onDelete={handleDeleteStatus}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
))}
|
||||
{localStatuses
|
||||
.filter(status => status.id)
|
||||
.map(status => (
|
||||
<SortableStatusItem
|
||||
key={status.id}
|
||||
id={status.id!}
|
||||
status={status}
|
||||
onRename={handleRenameStatus}
|
||||
onDelete={handleDeleteStatus}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
@@ -342,29 +366,25 @@ const StatusManagement: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
projectId,
|
||||
}) => {
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ open, onClose, projectId }) => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const [form] = Form.useForm();
|
||||
const [activeTab, setActiveTab] = useState('task-info');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
// Redux state
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
|
||||
const currentProjectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const user = useAppSelector(state => state.auth?.user);
|
||||
|
||||
|
||||
const finalProjectId = projectId || currentProjectId;
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
|
||||
const { socket } = useSocket();
|
||||
|
||||
|
||||
if (!socket || !user || !finalProjectId) {
|
||||
console.error('Missing socket, user, or project ID');
|
||||
return;
|
||||
@@ -383,15 +403,14 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
|
||||
// Create task via socket
|
||||
socket.emit(SocketEvents.QUICK_TASK.toString(), taskData);
|
||||
|
||||
|
||||
// Refresh task list
|
||||
dispatch(fetchTasksV3(finalProjectId));
|
||||
|
||||
|
||||
// Reset form and close modal
|
||||
form.resetFields();
|
||||
setActiveTab('task-info');
|
||||
onClose();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
@@ -423,9 +442,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
<Flex justify="space-between" align="center">
|
||||
<div></div>
|
||||
<Space>
|
||||
<Button onClick={handleCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>{t('cancel')}</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
{t('createTask')}
|
||||
</Button>
|
||||
@@ -465,65 +482,49 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('description')}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label={t('description')}>
|
||||
<Input.TextArea
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Status Selection */}
|
||||
<Form.Item
|
||||
name="status"
|
||||
label={t('status')}
|
||||
rules={[{ required: true, message: 'Please select a status' }]}
|
||||
>
|
||||
<Select placeholder="Select status">
|
||||
{/* TODO: Populate with actual statuses */}
|
||||
<Select.Option value="todo">To Do</Select.Option>
|
||||
<Select.Option value="inprogress">In Progress</Select.Option>
|
||||
<Select.Option value="done">Done</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{/* Status Selection */}
|
||||
<Form.Item
|
||||
name="status"
|
||||
label={t('status')}
|
||||
rules={[{ required: true, message: 'Please select a status' }]}
|
||||
>
|
||||
<Select placeholder="Select status">
|
||||
{/* TODO: Populate with actual statuses */}
|
||||
<Select.Option value="todo">To Do</Select.Option>
|
||||
<Select.Option value="inprogress">In Progress</Select.Option>
|
||||
<Select.Option value="done">Done</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Priority Selection */}
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label={t('priority')}
|
||||
>
|
||||
<Select placeholder="Select priority">
|
||||
<Select.Option value="low">Low</Select.Option>
|
||||
<Select.Option value="medium">Medium</Select.Option>
|
||||
<Select.Option value="high">High</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{/* Priority Selection */}
|
||||
<Form.Item name="priority" label={t('priority')}>
|
||||
<Select placeholder="Select priority">
|
||||
<Select.Option value="low">Low</Select.Option>
|
||||
<Select.Option value="medium">Medium</Select.Option>
|
||||
<Select.Option value="high">High</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Assignees */}
|
||||
<Form.Item
|
||||
name="assignees"
|
||||
label={t('assignees')}
|
||||
>
|
||||
<Select mode="multiple" placeholder="Select assignees">
|
||||
{/* TODO: Populate with team members */}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{/* Assignees */}
|
||||
<Form.Item name="assignees" label={t('assignees')}>
|
||||
<Select mode="multiple" placeholder="Select assignees">
|
||||
{/* TODO: Populate with team members */}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Due Date */}
|
||||
<Form.Item
|
||||
name="dueDate"
|
||||
label={t('dueDate')}
|
||||
>
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
placeholder="Select due date"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Due Date */}
|
||||
<Form.Item name="dueDate" label={t('dueDate')}>
|
||||
<DatePicker className="w-full" placeholder="Select due date" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
@@ -533,10 +534,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
label: t('manageStatuses'),
|
||||
children: finalProjectId ? (
|
||||
<div className="py-4">
|
||||
<StatusManagement
|
||||
projectId={finalProjectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<StatusManagement projectId={finalProjectId} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-center py-8 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
@@ -550,4 +548,4 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
export default CreateTaskModal;
|
||||
|
||||
@@ -276,7 +276,9 @@
|
||||
|
||||
/* Sortable item styling */
|
||||
.sortable-phase-item {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-phase-item.is-dragging {
|
||||
@@ -311,15 +313,15 @@
|
||||
width: 95% !important;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
|
||||
.dark-modal .ant-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
.phase-item {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
|
||||
.phase-color-picker {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -354,7 +356,9 @@
|
||||
.phase-item-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-item-exit {
|
||||
@@ -365,7 +369,9 @@
|
||||
.phase-item-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Loading state styling */
|
||||
@@ -380,4 +386,4 @@
|
||||
/* Divider styling for dark mode */
|
||||
.dark-modal .ant-divider-horizontal {
|
||||
border-color: #303030;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, ColorPicker, Tooltip } from '@/shared/antd-imports';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Space,
|
||||
Divider,
|
||||
Typography,
|
||||
Flex,
|
||||
ColorPicker,
|
||||
Tooltip,
|
||||
} from '@/shared/antd-imports';
|
||||
import { PlusOutlined, HolderOutlined, EditOutlined, DeleteOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
@@ -58,14 +69,9 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -85,13 +91,16 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
setIsEditing(false);
|
||||
}, [phase.name]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}, [handleSave, handleCancel]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleSave, handleCancel]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
@@ -132,8 +141,8 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`flex-shrink-0 cursor-grab active:cursor-grabbing p-1 rounded transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-600'
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-600'
|
||||
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
@@ -144,12 +153,12 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
<div className="flex-shrink-0 flex items-center gap-1">
|
||||
<ColorPicker
|
||||
value={color}
|
||||
onChange={(value) => setColor(value.toHexString())}
|
||||
onChange={value => setColor(value.toHexString())}
|
||||
onChangeComplete={handleColorChangeComplete}
|
||||
size="small"
|
||||
className="phase-color-picker"
|
||||
/>
|
||||
<div
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded border shadow-sm"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
@@ -164,12 +173,12 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`font-medium text-xs border-0 px-1 py-1 shadow-none ${
|
||||
isDarkMode
|
||||
? 'bg-transparent text-gray-200 placeholder-gray-400'
|
||||
isDarkMode
|
||||
? 'bg-transparent text-gray-200 placeholder-gray-400'
|
||||
: 'bg-transparent text-gray-900 placeholder-gray-500'
|
||||
}`}
|
||||
placeholder={t('enterPhaseName')}
|
||||
@@ -177,7 +186,9 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
) : (
|
||||
<Text
|
||||
className={`text-xs font-medium cursor-pointer transition-colors select-none ${
|
||||
isDarkMode ? 'text-gray-200 hover:text-gray-100' : 'text-gray-800 hover:text-gray-900'
|
||||
isDarkMode
|
||||
? 'text-gray-200 hover:text-gray-100'
|
||||
: 'text-gray-800 hover:text-gray-900'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
title={t('rename')}
|
||||
@@ -188,9 +199,11 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
</div>
|
||||
|
||||
{/* Hover Actions */}
|
||||
<div className={`flex items-center gap-1 transition-all duration-200 ${
|
||||
isHovered || isEditing ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
<div
|
||||
className={`flex items-center gap-1 transition-all duration-200 ${
|
||||
isHovered || isEditing ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Tooltip title={t('rename')}>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -198,8 +211,8 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={`h-6 w-6 flex items-center justify-center transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-600'
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-600'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
/>
|
||||
@@ -211,8 +224,8 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onDelete(id)}
|
||||
className={`h-6 w-6 flex items-center justify-center transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'text-red-400 hover:text-red-300 hover:bg-red-800'
|
||||
isDarkMode
|
||||
? 'text-red-400 hover:text-red-300 hover:bg-red-800'
|
||||
: 'text-red-500 hover:text-red-600 hover:bg-red-50'
|
||||
}`}
|
||||
/>
|
||||
@@ -223,20 +236,16 @@ const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
projectId,
|
||||
}) => {
|
||||
const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({ open, onClose, projectId }) => {
|
||||
const { t } = useTranslation('phases-drawer');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
// Redux state
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
|
||||
const currentProjectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const { phaseList, loadingPhases } = useAppSelector(state => state.phaseReducer);
|
||||
|
||||
|
||||
const [phaseName, setPhaseName] = useState<string>(project?.phase_label || '');
|
||||
const [initialPhaseName, setInitialPhaseName] = useState<string>(project?.phase_label || '');
|
||||
const [sorting, setSorting] = useState(false);
|
||||
@@ -270,39 +279,42 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
}
|
||||
}, [finalProjectId, dispatch]);
|
||||
|
||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
||||
if (!finalProjectId) return;
|
||||
const { active, over } = event;
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
if (!finalProjectId) return;
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = phaseList.findIndex(item => item.id === active.id);
|
||||
const newIndex = phaseList.findIndex(item => item.id === over.id);
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = phaseList.findIndex(item => item.id === active.id);
|
||||
const newIndex = phaseList.findIndex(item => item.id === over.id);
|
||||
|
||||
const newPhaseList = [...phaseList];
|
||||
const [movedItem] = newPhaseList.splice(oldIndex, 1);
|
||||
newPhaseList.splice(newIndex, 0, movedItem);
|
||||
const newPhaseList = [...phaseList];
|
||||
const [movedItem] = newPhaseList.splice(oldIndex, 1);
|
||||
newPhaseList.splice(newIndex, 0, movedItem);
|
||||
|
||||
try {
|
||||
setSorting(true);
|
||||
dispatch(updatePhaseListOrder(newPhaseList));
|
||||
try {
|
||||
setSorting(true);
|
||||
dispatch(updatePhaseListOrder(newPhaseList));
|
||||
|
||||
const body = {
|
||||
from_index: oldIndex,
|
||||
to_index: newIndex,
|
||||
phases: newPhaseList,
|
||||
project_id: finalProjectId,
|
||||
};
|
||||
const body = {
|
||||
from_index: oldIndex,
|
||||
to_index: newIndex,
|
||||
phases: newPhaseList,
|
||||
project_id: finalProjectId,
|
||||
};
|
||||
|
||||
await dispatch(updatePhaseOrder({ projectId: finalProjectId, body })).unwrap();
|
||||
await refreshTasks();
|
||||
} catch (error) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
console.error('Error updating phase order', error);
|
||||
} finally {
|
||||
setSorting(false);
|
||||
await dispatch(updatePhaseOrder({ projectId: finalProjectId, body })).unwrap();
|
||||
await refreshTasks();
|
||||
} catch (error) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
console.error('Error updating phase order', error);
|
||||
} finally {
|
||||
setSorting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
|
||||
},
|
||||
[finalProjectId, phaseList, dispatch, refreshTasks]
|
||||
);
|
||||
|
||||
const handleCreatePhase = useCallback(async () => {
|
||||
if (!newPhaseName.trim() || !finalProjectId) return;
|
||||
@@ -318,96 +330,108 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
}
|
||||
}, [finalProjectId, dispatch, refreshTasks, newPhaseName]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreatePhase();
|
||||
} else if (e.key === 'Escape') {
|
||||
setNewPhaseName('');
|
||||
setShowAddForm(false);
|
||||
}
|
||||
}, [handleCreatePhase]);
|
||||
|
||||
const handleRenamePhase = useCallback(async (id: string, name: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
try {
|
||||
const phase = phaseList.find(p => p.id === id);
|
||||
if (!phase) return;
|
||||
|
||||
const updatedPhase = { ...phase, name: name.trim() };
|
||||
const response = await dispatch(
|
||||
updatePhaseName({
|
||||
phaseId: id,
|
||||
phase: updatedPhase,
|
||||
projectId: finalProjectId,
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
if (response.done) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
await refreshTasks();
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreatePhase();
|
||||
} else if (e.key === 'Escape') {
|
||||
setNewPhaseName('');
|
||||
setShowAddForm(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming phase:', error);
|
||||
}
|
||||
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
|
||||
},
|
||||
[handleCreatePhase]
|
||||
);
|
||||
|
||||
const handleDeletePhase = useCallback(async (id: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
AntModal.confirm({
|
||||
title: t('deletePhase'),
|
||||
content: t('deletePhaseConfirm'),
|
||||
okText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await dispatch(
|
||||
deletePhaseOption({ phaseOptionId: id, projectId: finalProjectId })
|
||||
).unwrap();
|
||||
const handleRenamePhase = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
if (response.done) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
await refreshTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting phase:', error);
|
||||
try {
|
||||
const phase = phaseList.find(p => p.id === id);
|
||||
if (!phase) return;
|
||||
|
||||
const updatedPhase = { ...phase, name: name.trim() };
|
||||
const response = await dispatch(
|
||||
updatePhaseName({
|
||||
phaseId: id,
|
||||
phase: updatedPhase,
|
||||
projectId: finalProjectId,
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
if (response.done) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
await refreshTasks();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [finalProjectId, dispatch, refreshTasks, t]);
|
||||
|
||||
const handleColorChange = useCallback(async (id: string, color: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
try {
|
||||
const phase = phaseList.find(p => p.id === id);
|
||||
if (!phase) return;
|
||||
|
||||
const updatedPhase = { ...phase, color_code: color };
|
||||
const response = await dispatch(
|
||||
updatePhaseColor({ projectId: finalProjectId, body: updatedPhase })
|
||||
).unwrap();
|
||||
|
||||
if (response.done) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
await refreshTasks();
|
||||
} catch (error) {
|
||||
console.error('Error renaming phase:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error changing phase color:', error);
|
||||
}
|
||||
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
|
||||
},
|
||||
[finalProjectId, phaseList, dispatch, refreshTasks]
|
||||
);
|
||||
|
||||
const handleDeletePhase = useCallback(
|
||||
async (id: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
AntModal.confirm({
|
||||
title: t('deletePhase'),
|
||||
content: t('deletePhaseConfirm'),
|
||||
okText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await dispatch(
|
||||
deletePhaseOption({ phaseOptionId: id, projectId: finalProjectId })
|
||||
).unwrap();
|
||||
|
||||
if (response.done) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
await refreshTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting phase:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[finalProjectId, dispatch, refreshTasks, t]
|
||||
);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
async (id: string, color: string) => {
|
||||
if (!finalProjectId) return;
|
||||
|
||||
try {
|
||||
const phase = phaseList.find(p => p.id === id);
|
||||
if (!phase) return;
|
||||
|
||||
const updatedPhase = { ...phase, color_code: color };
|
||||
const response = await dispatch(
|
||||
updatePhaseColor({ projectId: finalProjectId, body: updatedPhase })
|
||||
).unwrap();
|
||||
|
||||
if (response.done) {
|
||||
dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
await refreshTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error changing phase color:', error);
|
||||
}
|
||||
},
|
||||
[finalProjectId, phaseList, dispatch, refreshTasks]
|
||||
);
|
||||
|
||||
const handlePhaseNameBlur = useCallback(async () => {
|
||||
if (!finalProjectId || phaseName === initialPhaseName) return;
|
||||
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const res = await dispatch(
|
||||
updateProjectPhaseLabel({ projectId: finalProjectId, phaseLabel: phaseName })
|
||||
).unwrap();
|
||||
|
||||
|
||||
if (res.done) {
|
||||
dispatch(updatePhaseLabel(phaseName));
|
||||
setInitialPhaseName(phaseName);
|
||||
@@ -427,9 +451,10 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Title level={4} className={`m-0 font-semibold ${
|
||||
isDarkMode ? 'text-gray-100' : 'text-gray-800'
|
||||
}`}>
|
||||
<Title
|
||||
level={4}
|
||||
className={`m-0 font-semibold ${isDarkMode ? 'text-gray-100' : 'text-gray-800'}`}
|
||||
>
|
||||
{t('configure')} {phaseName || project?.phase_label || t('phasesText')}
|
||||
</Title>
|
||||
}
|
||||
@@ -445,14 +470,14 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
},
|
||||
}}
|
||||
footer={
|
||||
<div className={`flex justify-end pt-3 ${
|
||||
isDarkMode ? 'border-gray-700' : 'border-gray-200'
|
||||
}`}>
|
||||
<Button
|
||||
<div
|
||||
className={`flex justify-end pt-3 ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}
|
||||
>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className={`font-medium ${
|
||||
isDarkMode
|
||||
? 'text-gray-300 hover:text-gray-200 border-gray-600'
|
||||
isDarkMode
|
||||
? 'text-gray-300 hover:text-gray-200 border-gray-600'
|
||||
: 'text-gray-600 hover:text-gray-800 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
@@ -465,15 +490,17 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Phase Label Configuration */}
|
||||
<div className={`p-3 rounded border transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 text-gray-300'
|
||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
}`}>
|
||||
<div
|
||||
className={`p-3 rounded border transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 text-gray-300'
|
||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-blue-700'
|
||||
}`}>
|
||||
<Text
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-blue-700'}`}
|
||||
>
|
||||
{t('phaseLabel')}
|
||||
</Text>
|
||||
<Input
|
||||
@@ -489,34 +516,40 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className={`p-3 rounded border transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 text-gray-300'
|
||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
}`}>
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-blue-700'
|
||||
}`}>
|
||||
🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to reorder them. Click on a {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it. Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a custom color.
|
||||
<div
|
||||
className={`p-3 rounded border transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 text-gray-300'
|
||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-blue-700'}`}>
|
||||
🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to
|
||||
reorder them. Click on a{' '}
|
||||
{(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it.
|
||||
Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a
|
||||
custom color.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Add New Phase Form */}
|
||||
{showAddForm && (
|
||||
<div className={`p-2 rounded border-2 border-dashed transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-700 hover:border-gray-500'
|
||||
: 'border-gray-300 bg-white hover:border-gray-400'
|
||||
} shadow-sm`}>
|
||||
<div
|
||||
className={`p-2 rounded border-2 border-dashed transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-700 hover:border-gray-500'
|
||||
: 'border-gray-300 bg-white hover:border-gray-400'
|
||||
} shadow-sm`}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('enterNewPhaseName')}
|
||||
value={newPhaseName}
|
||||
onChange={(e) => setNewPhaseName(e.target.value)}
|
||||
onChange={e => setNewPhaseName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`flex-1 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-600 border-gray-500 text-gray-100 placeholder-gray-400'
|
||||
isDarkMode
|
||||
? 'bg-gray-600 border-gray-500 text-gray-100 placeholder-gray-400'
|
||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500'
|
||||
}`}
|
||||
size="small"
|
||||
@@ -538,8 +571,8 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
}}
|
||||
size="small"
|
||||
className={`text-xs ${
|
||||
isDarkMode
|
||||
? 'text-gray-300 hover:text-gray-200 border-gray-600'
|
||||
isDarkMode
|
||||
? 'text-gray-300 hover:text-gray-200 border-gray-600'
|
||||
: 'text-gray-600 hover:text-gray-800 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
@@ -551,20 +584,22 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
|
||||
{/* Add Phase Button */}
|
||||
{!showAddForm && (
|
||||
<div className={`p-3 rounded border-2 border-dashed transition-colors ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-gray-500'
|
||||
: 'border-gray-300 bg-gray-50/50 hover:border-gray-400'
|
||||
}`}>
|
||||
<div
|
||||
className={`p-3 rounded border-2 border-dashed transition-colors ${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-800/50 hover:border-gray-500'
|
||||
: 'border-gray-300 bg-gray-50/50 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-gray-700'
|
||||
}`}>
|
||||
<Text
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
||||
>
|
||||
{phaseName || project?.phase_label || t('phasesText')} {t('optionsText')}
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={loadingPhases}
|
||||
size="small"
|
||||
@@ -583,7 +618,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{phaseList.map((phase) => (
|
||||
{phaseList.map(phase => (
|
||||
<SortablePhaseItem
|
||||
key={phase.id}
|
||||
id={phase.id}
|
||||
@@ -599,11 +634,14 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
</DndContext>
|
||||
|
||||
{phaseList.length === 0 && (
|
||||
<div className={`text-center py-8 transition-colors ${
|
||||
isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}>
|
||||
<div
|
||||
className={`text-center py-8 transition-colors ${
|
||||
isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Text className="text-sm font-medium">
|
||||
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} {t('found')}
|
||||
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()}{' '}
|
||||
{t('found')}
|
||||
</Text>
|
||||
<br />
|
||||
<Button
|
||||
@@ -611,8 +649,8 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
size="small"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className={`text-xs mt-1 font-medium ${
|
||||
isDarkMode
|
||||
? 'text-blue-400 hover:text-blue-300'
|
||||
isDarkMode
|
||||
? 'text-blue-400 hover:text-blue-300'
|
||||
: 'text-blue-600 hover:text-blue-700'
|
||||
}`}
|
||||
>
|
||||
@@ -625,4 +663,4 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePhaseModal;
|
||||
export default ManagePhaseModal;
|
||||
|
||||
@@ -292,7 +292,9 @@
|
||||
|
||||
/* Sortable item styling */
|
||||
.sortable-status-item {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-status-item.is-dragging {
|
||||
@@ -311,11 +313,11 @@
|
||||
width: 95% !important;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
|
||||
.dark-modal .ant-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
.status-item {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -340,7 +342,9 @@
|
||||
.status-item-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.status-item-exit {
|
||||
@@ -351,5 +355,7 @@
|
||||
.status-item-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@ import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||
import { toggleField, syncFieldWithDatabase } from '@/features/task-management/taskListFields.slice';
|
||||
import {
|
||||
toggleField,
|
||||
syncFieldWithDatabase,
|
||||
} from '@/features/task-management/taskListFields.slice';
|
||||
import { selectColumns } from '@/features/task-management/task-management.slice';
|
||||
|
||||
// Import Redux actions
|
||||
@@ -84,7 +87,7 @@ const FILTER_DEBOUNCE_DELAY = 300; // ms
|
||||
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
||||
const MAX_FILTER_OPTIONS = 100;
|
||||
|
||||
// Limit options to prevent UI lag
|
||||
// Limit options to prevent UI lag
|
||||
|
||||
// Optimized selectors with proper transformation logic
|
||||
const selectFilterData = createSelector(
|
||||
@@ -452,7 +455,7 @@ const FilterDropdown: React.FC<{
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`
|
||||
className={`
|
||||
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
|
||||
border transition-all duration-200 ease-in-out
|
||||
${
|
||||
@@ -689,19 +692,19 @@ const SearchFilter: React.FC<{
|
||||
value={localValue}
|
||||
onChange={e => setLocalValue(e.target.value)}
|
||||
placeholder={placeholder || t('searchTasks') || 'Search tasks by name or key...'}
|
||||
className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors duration-150 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
||||
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
|
||||
}`}
|
||||
className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors duration-150 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
||||
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{localValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={`absolute right-1.5 top-1/2 transform -translate-y-1/2 transition-colors duration-150 ${
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-200'
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-200'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -716,9 +719,9 @@ const SearchFilter: React.FC<{
|
||||
? 'text-white bg-gray-600 hover:bg-gray-700'
|
||||
: 'text-gray-800 bg-gray-200 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('search')}
|
||||
</button>
|
||||
>
|
||||
{t('search')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -727,9 +730,7 @@ const SearchFilter: React.FC<{
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 ${
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-200'
|
||||
: 'text-gray-600 hover:text-gray-800'
|
||||
isDarkMode ? 'text-gray-400 hover:text-gray-200' : 'text-gray-600 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{t('cancel')}
|
||||
@@ -751,30 +752,33 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Helper function to get translated field label using existing task-list-table translations
|
||||
const getFieldLabel = useCallback((fieldKey: string) => {
|
||||
const keyMappings: Record<string, string> = {
|
||||
'KEY': 'keyColumn',
|
||||
'DESCRIPTION': 'descriptionColumn',
|
||||
'PROGRESS': 'progressColumn',
|
||||
'ASSIGNEES': 'assigneesColumn',
|
||||
'LABELS': 'labelsColumn',
|
||||
'PHASE': 'phaseColumn',
|
||||
'STATUS': 'statusColumn',
|
||||
'PRIORITY': 'priorityColumn',
|
||||
'TIME_TRACKING': 'timeTrackingColumn',
|
||||
'ESTIMATION': 'estimationColumn',
|
||||
'START_DATE': 'startDateColumn',
|
||||
'DUE_DATE': 'dueDateColumn',
|
||||
'DUE_TIME': 'dueTimeColumn',
|
||||
'COMPLETED_DATE': 'completedDateColumn',
|
||||
'CREATED_DATE': 'createdDateColumn',
|
||||
'LAST_UPDATED': 'lastUpdatedColumn',
|
||||
'REPORTER': 'reporterColumn',
|
||||
};
|
||||
const getFieldLabel = useCallback(
|
||||
(fieldKey: string) => {
|
||||
const keyMappings: Record<string, string> = {
|
||||
KEY: 'keyColumn',
|
||||
DESCRIPTION: 'descriptionColumn',
|
||||
PROGRESS: 'progressColumn',
|
||||
ASSIGNEES: 'assigneesColumn',
|
||||
LABELS: 'labelsColumn',
|
||||
PHASE: 'phaseColumn',
|
||||
STATUS: 'statusColumn',
|
||||
PRIORITY: 'priorityColumn',
|
||||
TIME_TRACKING: 'timeTrackingColumn',
|
||||
ESTIMATION: 'estimationColumn',
|
||||
START_DATE: 'startDateColumn',
|
||||
DUE_DATE: 'dueDateColumn',
|
||||
DUE_TIME: 'dueTimeColumn',
|
||||
COMPLETED_DATE: 'completedDateColumn',
|
||||
CREATED_DATE: 'createdDateColumn',
|
||||
LAST_UPDATED: 'lastUpdatedColumn',
|
||||
REPORTER: 'reporterColumn',
|
||||
};
|
||||
|
||||
const translationKey = keyMappings[fieldKey];
|
||||
return translationKey ? tTable(translationKey) : fieldKey;
|
||||
}, [tTable]);
|
||||
const translationKey = keyMappings[fieldKey];
|
||||
return translationKey ? tTable(translationKey) : fieldKey;
|
||||
},
|
||||
[tTable]
|
||||
);
|
||||
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
|
||||
const columns = useSelector(selectColumns);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
@@ -859,9 +863,9 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
{/* Options List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{sortedFields.length === 0 ? (
|
||||
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
||||
{t('noOptionsFound')}
|
||||
</div>
|
||||
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
||||
{t('noOptionsFound')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-0.5">
|
||||
{sortedFields.map(field => {
|
||||
@@ -873,15 +877,17 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
onClick={() => {
|
||||
// Toggle field locally first
|
||||
dispatch(toggleField(field.key));
|
||||
|
||||
|
||||
// Sync with database if projectId is available
|
||||
if (projectId) {
|
||||
dispatch(syncFieldWithDatabase({
|
||||
projectId,
|
||||
fieldKey: field.key,
|
||||
visible: !field.visible,
|
||||
columns
|
||||
}));
|
||||
dispatch(
|
||||
syncFieldWithDatabase({
|
||||
projectId,
|
||||
fieldKey: field.key,
|
||||
visible: !field.visible,
|
||||
columns,
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
@@ -949,7 +955,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
// Get search value from Redux based on position
|
||||
const taskManagementSearch = useAppSelector(state => state.taskManagement?.search || '');
|
||||
const kanbanSearch = useAppSelector(state => state.enhancedKanbanReducer?.search || '');
|
||||
|
||||
|
||||
const searchValue = position === 'board' ? kanbanSearch : taskManagementSearch;
|
||||
|
||||
// Local state for filter sections
|
||||
@@ -957,7 +963,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
||||
const [clearingFilters, setClearingFilters] = useState(false);
|
||||
|
||||
|
||||
// Modal state
|
||||
const [showManageStatusModal, setShowManageStatusModal] = useState(false);
|
||||
const [showManagePhaseModal, setShowManagePhaseModal] = useState(false);
|
||||
@@ -1306,12 +1312,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
))
|
||||
) : (
|
||||
// Loading state
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}
|
||||
>
|
||||
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-gray-500"></div>
|
||||
<span>{t('loadingFilters')}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}
|
||||
>
|
||||
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-gray-500"></div>
|
||||
<span>{t('loadingFilters')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1321,17 +1327,18 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs ${themeClasses.secondaryText}`}>
|
||||
{activeFiltersCount} {activeFiltersCount !== 1 ? t('filtersActive') : t('filterActive')}
|
||||
{activeFiltersCount}{' '}
|
||||
{activeFiltersCount !== 1 ? t('filtersActive') : t('filterActive')}
|
||||
</span>
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
disabled={clearingFilters}
|
||||
className={`text-xs font-medium transition-colors duration-150 ${
|
||||
clearingFilters
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300'
|
||||
: 'text-gray-600 hover:text-gray-700'
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300'
|
||||
: 'text-gray-600 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{clearingFilters ? t('clearing') : t('clearAll')}
|
||||
@@ -1362,7 +1369,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Modals */}
|
||||
<ManageStatusModal
|
||||
open={showManageStatusModal}
|
||||
@@ -1373,7 +1380,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
}}
|
||||
projectId={projectId || undefined}
|
||||
/>
|
||||
|
||||
|
||||
<ManagePhaseModal
|
||||
open={showManagePhaseModal}
|
||||
onClose={() => {
|
||||
|
||||
@@ -76,7 +76,12 @@ const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
||||
// Once loaded, show the full component
|
||||
return (
|
||||
<Suspense fallback={<LoadingPlaceholder isDarkMode={isDarkMode} />}>
|
||||
<LazyAssigneeSelector task={task} groupId={groupId} isDarkMode={isDarkMode} kanbanMode={kanbanMode} />
|
||||
<LazyAssigneeSelector
|
||||
task={task}
|
||||
groupId={groupId}
|
||||
isDarkMode={isDarkMode}
|
||||
kanbanMode={kanbanMode}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user