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:
@@ -0,0 +1,553 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Button, Tabs, Space, Divider, Typography, Flex, DatePicker, Select } from 'antd';
|
||||
import { PlusOutlined, DragOutlined } 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 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 { 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 { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||
import './CreateTaskModal.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
interface StatusItemProps {
|
||||
status: any;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
// Sortable Status Item Component
|
||||
const SortableStatusItem: React.FC<StatusItemProps & { id: string }> = ({
|
||||
id,
|
||||
status,
|
||||
onRename,
|
||||
onDelete,
|
||||
isDarkMode,
|
||||
}) => {
|
||||
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={`flex items-center gap-3 p-3 rounded-md border transition-all duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-700 hover:bg-gray-750'
|
||||
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`cursor-grab active:cursor-grabbing p-1 rounded ${
|
||||
isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<DragOutlined />
|
||||
</div>
|
||||
|
||||
{/* Status Color */}
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: status.color_code || '#gray',
|
||||
borderColor: isDarkMode ? '#374151' : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status Name */}
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="small"
|
||||
className="font-medium"
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
className={`font-medium cursor-pointer ${
|
||||
isDarkMode ? 'text-gray-200' : 'text-gray-800'
|
||||
}`}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{status.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-600'}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => onDelete(id)}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Status Management Component
|
||||
const StatusManagement: React.FC<{
|
||||
projectId: string;
|
||||
isDarkMode: boolean;
|
||||
}> = ({ 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('');
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalStatuses(statuses);
|
||||
}, [statuses]);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
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, projectId).catch(error => {
|
||||
console.error('Error updating status order:', error);
|
||||
});
|
||||
|
||||
return newItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreateStatus = useCallback(async () => {
|
||||
if (!newStatusName.trim()) 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: projectId,
|
||||
};
|
||||
|
||||
const res = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
|
||||
if (res.done) {
|
||||
setNewStatusName('');
|
||||
dispatch(fetchStatuses(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating status:', error);
|
||||
}
|
||||
}, [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 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">
|
||||
<div className="flex items-center justify-between">
|
||||
<Title level={5} className={isDarkMode ? 'text-gray-200' : 'text-gray-800'}>
|
||||
{t('manageStatuses')}
|
||||
</Title>
|
||||
<Text type="secondary" className="text-sm">
|
||||
Drag to reorder
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Create New Status */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter status name"
|
||||
value={newStatusName}
|
||||
onChange={(e) => setNewStatusName(e.target.value)}
|
||||
onPressEnter={handleCreateStatus}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateStatus}
|
||||
disabled={!newStatusName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</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}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
name: values.name,
|
||||
description: values.description || null,
|
||||
project_id: finalProjectId,
|
||||
status_id: values.status || null,
|
||||
priority_id: values.priority || null,
|
||||
assignees: values.assignees || [],
|
||||
due_date: values.dueDate ? values.dueDate.format('YYYY-MM-DD') : null,
|
||||
reporter_id: user.id,
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}, [form, finalProjectId, dispatch, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.resetFields();
|
||||
setActiveTab('task-info');
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Title level={4} className={isDarkMode ? 'text-gray-200' : 'text-gray-800'}>
|
||||
{t('createTask')}
|
||||
</Title>
|
||||
}
|
||||
open={open}
|
||||
onCancel={handleCancel}
|
||||
width={800}
|
||||
style={{ top: 20 }}
|
||||
bodyStyle={{
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
padding: '24px',
|
||||
}}
|
||||
footer={
|
||||
<Flex justify="space-between" align="center">
|
||||
<div></div>
|
||||
<Space>
|
||||
<Button onClick={handleCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
{t('createTask')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
}
|
||||
className={isDarkMode ? 'dark-modal' : ''}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
type="card"
|
||||
items={[
|
||||
{
|
||||
key: 'task-info',
|
||||
label: t('taskInfo'),
|
||||
children: (
|
||||
<div className="py-4">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
priority: 'medium',
|
||||
billable: false,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('taskName')}
|
||||
rules={[{ required: true, message: t('taskNameRequired') }]}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('taskNamePlaceholder')}
|
||||
autoFocus
|
||||
maxLength={250}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Due Date */}
|
||||
<Form.Item
|
||||
name="dueDate"
|
||||
label={t('dueDate')}
|
||||
>
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
placeholder="Select due date"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status-management',
|
||||
label: t('manageStatuses'),
|
||||
children: finalProjectId ? (
|
||||
<div className="py-4">
|
||||
<StatusManagement
|
||||
projectId={finalProjectId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-center py-8 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
<Text>Project ID is required for status management.</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
Reference in New Issue
Block a user