feat(task-management): introduce modals for managing phases and statuses

- Added CreateTaskModal for task creation with integrated status management.
- Implemented ManagePhaseModal and ManageStatusModal for phase and status management, respectively, featuring drag-and-drop functionality.
- Enhanced UI with new CSS styles for dark mode and improved accessibility.
- Updated filter data loading to include phases and statuses for better task management experience.
- Ensured all new components are responsive and align with existing design patterns.
This commit is contained in:
chamiakJ
2025-07-10 18:13:41 +05:30
parent 857b48e225
commit cf686ef8c5
20 changed files with 2467 additions and 28 deletions

View File

@@ -0,0 +1,469 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, Select } from 'antd';
import { PlusOutlined, HolderOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
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 'antd';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import './ManageStatusModal.css';
const { Title, Text } = Typography;
interface ManageStatusModalProps {
open: boolean;
onClose: () => void;
projectId?: string;
}
interface StatusItemProps {
status: any;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
onCategoryChange: (id: string, categoryId: string) => void;
isDarkMode: boolean;
categories: any[];
}
// Sortable Status Item Component
const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
id,
status,
onRename,
onDelete,
onCategoryChange,
isDarkMode,
categories,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(status.name || '');
const inputRef = useRef<any>(null);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const handleSave = useCallback(() => {
if (editName.trim() && editName.trim() !== status.name) {
onRename(id, editName.trim());
}
setIsEditing(false);
}, [editName, id, onRename, status.name]);
const handleCancel = useCallback(() => {
setEditName(status.name || '');
setIsEditing(false);
}, [status.name]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
handleCancel();
}
}, [handleSave, handleCancel]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
return (
<div
ref={setNodeRef}
style={style}
className={`p-3 rounded-md border transition-all duration-200 ${
isDarkMode
? 'bg-gray-800 border-gray-700 hover:bg-gray-750'
: 'bg-white border-gray-200 hover:bg-gray-50 shadow-sm'
}`}
>
{/* Header Row - Drag Handle, Color, Name, Actions */}
<div className="flex items-center gap-2 mb-2">
{/* Drag Handle */}
<div
{...attributes}
{...listeners}
className={`cursor-grab active:cursor-grabbing p-1 rounded transition-colors ${
isDarkMode
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700'
: 'text-gray-500 hover:text-gray-600 hover:bg-gray-100'
}`}
>
<HolderOutlined />
</div>
{/* Status Color */}
<div
className="w-4 h-4 rounded-full border shadow-sm"
style={{
backgroundColor: status.color_code || '#gray',
borderColor: isDarkMode ? '#374151' : '#e5e7eb',
}}
/>
{/* Status Name */}
<div className="flex-1">
{isEditing ? (
<Input
ref={inputRef}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="font-medium"
placeholder="Enter status name"
/>
) : (
<Text
className={`text-sm font-medium cursor-pointer transition-colors ${
isDarkMode ? 'text-gray-100 hover:text-white' : 'text-gray-900 hover:text-gray-700'
}`}
onClick={() => setIsEditing(true)}
>
{status.name}
</Text>
)}
</div>
{/* Actions */}
<Space size={4}>
<Button
type="text"
size="small"
onClick={() => setIsEditing(true)}
className={`text-xs ${isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-600'} hover:bg-transparent`}
>
Rename
</Button>
<Button
type="text"
size="small"
danger
onClick={() => onDelete(id)}
className="text-xs text-red-500 hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Delete
</Button>
</Space>
</div>
{/* Category Row */}
<div className="flex items-center gap-1.5">
<Text
className={`text-xs font-medium ${
isDarkMode ? 'text-gray-400' : 'text-gray-600'
}`}
>
Category:
</Text>
<Select
value={status.category_id}
onChange={(value) => onCategoryChange(id, value)}
size="small"
style={{ width: 120 }}
className="category-select"
placeholder="Select category"
>
{categories.map(category => (
<Select.Option key={category.id} value={category.id}>
{category.name}
</Select.Option>
))}
</Select>
</div>
</div>
);
};
const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
open,
onClose,
projectId,
}) => {
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
// Redux state
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
const currentProjectId = useAppSelector(state => state.projectReducer.projectId);
const { status: statuses } = useAppSelector(state => state.taskStatusReducer);
const [localStatuses, setLocalStatuses] = useState(statuses);
const [newStatusName, setNewStatusName] = useState('');
const [statusCategories, setStatusCategories] = useState<any[]>([]);
const finalProjectId = projectId || currentProjectId;
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
useEffect(() => {
setLocalStatuses(statuses);
}, [statuses]);
useEffect(() => {
if (open && finalProjectId) {
dispatch(fetchStatuses(finalProjectId));
// Fetch status categories
dispatch(fetchStatusesCategories()).then((result: any) => {
if (result.payload && Array.isArray(result.payload)) {
setStatusCategories(result.payload);
}
}).catch(() => {
setStatusCategories([]);
});
}
}, [open, finalProjectId, dispatch]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !finalProjectId) {
return;
}
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;
const newItems = [...items];
const [movedItem] = newItems.splice(oldIndex, 1);
newItems.splice(newIndex, 0, movedItem);
// Update status order via API (fire and forget)
const columnOrder = newItems.map(item => item.id).filter(Boolean) as string[];
const requestBody = { status_order: columnOrder };
statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => {
// Refresh enhanced kanban after status order change
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
}).catch(error => {
console.error('Error updating status order:', error);
});
return newItems;
});
}, [finalProjectId]);
const handleCreateStatus = useCallback(async () => {
if (!newStatusName.trim() || !finalProjectId) return;
try {
const statusCategories = await dispatch(fetchStatusesCategories()).unwrap();
const defaultCategory = statusCategories[0]?.id;
if (!defaultCategory) {
console.error('No status categories found');
return;
}
const body = {
name: newStatusName.trim(),
category_id: defaultCategory,
project_id: finalProjectId,
};
const res = await dispatch(createStatus({ body, currentProjectId: finalProjectId })).unwrap();
if (res.done) {
setNewStatusName('');
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
}
} catch (error) {
console.error('Error creating status:', error);
}
}, [newStatusName, finalProjectId, dispatch]);
const handleRenameStatus = useCallback(async (id: string, name: string) => {
if (!finalProjectId) return;
try {
const body: ITaskStatusUpdateModel = {
name: name.trim(),
project_id: finalProjectId,
};
await statusApiService.updateNameOfStatus(id, body, finalProjectId);
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error renaming status:', error);
}
}, [finalProjectId, dispatch]);
const handleDeleteStatus = useCallback(async (id: string) => {
if (!finalProjectId) return;
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, finalProjectId, replacingStatusId);
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error deleting status:', error);
}
},
});
}, [localStatuses, finalProjectId, dispatch]);
const handleCategoryChange = useCallback(async (id: string, categoryId: string) => {
if (!finalProjectId) return;
try {
const body: ITaskStatusUpdateModel = {
category_id: categoryId,
project_id: finalProjectId,
};
await statusApiService.updateNameOfStatus(id, body, finalProjectId);
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error changing status category:', error);
}
}, [finalProjectId, dispatch]);
const handleClose = useCallback(() => {
setNewStatusName('');
onClose();
}, [onClose]);
return (
<Modal
title={
<Title level={4} className={isDarkMode ? 'text-gray-200' : 'text-gray-800'}>
{t('manageStatuses')}
</Title>
}
open={open}
onCancel={handleClose}
width={600}
style={{ top: 20 }}
bodyStyle={{
maxHeight: 'calc(100vh - 200px)',
overflowY: 'auto',
padding: '24px',
}}
footer={
<Flex justify="flex-end">
<Button onClick={handleClose}>
Close
</Button>
</Flex>
}
className={isDarkMode ? 'dark-modal' : ''}
>
<div className="space-y-4">
<div className={`p-2.5 rounded-md ${
isDarkMode ? 'bg-gray-800/50' : 'bg-blue-50'
}`}>
<Text
type="secondary"
className={`text-xs ${
isDarkMode ? 'text-gray-400' : 'text-blue-600'
}`}
>
💡 Drag statuses to reorder them. Each status can have a different category.
</Text>
</div>
{/* Create New Status */}
<div className={`p-3 rounded-md 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 gap-2">
<Input
placeholder="Enter new status name..."
value={newStatusName}
onChange={(e) => setNewStatusName(e.target.value)}
onPressEnter={handleCreateStatus}
className="flex-1"
size="small"
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreateStatus}
disabled={!newStatusName.trim()}
size="small"
>
Add Status
</Button>
</div>
</div>
<Divider className={isDarkMode ? 'border-gray-700' : 'border-gray-300'} />
{/* Status List with Drag & Drop */}
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={localStatuses.filter(status => status.id).map(status => status.id as string)}
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}
onCategoryChange={handleCategoryChange}
isDarkMode={isDarkMode}
categories={statusCategories}
/>
))}
</div>
</SortableContext>
</DndContext>
{localStatuses.length === 0 && (
<div className={`text-center py-8 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
<Text>No statuses found. Create your first status above.</Text>
</div>
)}
</div>
</Modal>
);
};
export default ManageStatusModal;