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,188 @@
/* CreateTaskModal.css */
/* Dark mode modal styling */
.dark-modal .ant-modal-content {
background-color: #1f1f1f;
border: 1px solid #303030;
}
.dark-modal .ant-modal-header {
background-color: #1f1f1f;
border-bottom: 1px solid #303030;
}
.dark-modal .ant-modal-body {
background-color: #1f1f1f;
}
.dark-modal .ant-modal-footer {
background-color: #1f1f1f;
border-top: 1px solid #303030;
}
.dark-modal .ant-tabs-content-holder {
background-color: #1f1f1f;
}
.dark-modal .ant-tabs-tab {
color: #d9d9d9;
}
.dark-modal .ant-tabs-tab-active {
color: #ffffff;
}
.dark-modal .ant-form-item-label > label {
color: #d9d9d9;
}
.dark-modal .ant-input,
.dark-modal .ant-select-selector,
.dark-modal .ant-picker {
background-color: #141414;
border-color: #303030;
color: #d9d9d9;
}
.dark-modal .ant-input::placeholder,
.dark-modal .ant-select-selection-placeholder {
color: #8c8c8c;
}
.dark-modal .ant-select-dropdown {
background-color: #1f1f1f;
border-color: #303030;
}
.dark-modal .ant-select-item {
color: #d9d9d9;
}
.dark-modal .ant-select-item-option-selected {
background-color: #262626;
}
.dark-modal .ant-select-item:hover {
background-color: #262626;
}
/* Status management section styling */
.status-item {
transition: all 0.2s ease;
}
.status-item:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dark-modal .status-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Drag handle styling */
.drag-handle {
cursor: grab;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.drag-handle:hover {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
/* Status color indicator */
.status-color {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.dark-modal .status-color {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
/* Sortable item styling */
.sortable-status-item {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.sortable-status-item.is-dragging {
transform: scale(1.02);
z-index: 999;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
.dark-modal .sortable-status-item.is-dragging {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
}
/* Form styling improvements */
.dark-modal .ant-form-item-required::before {
color: #ff4d4f;
}
.dark-modal .ant-btn-primary {
background-color: #1890ff;
border-color: #1890ff;
}
.dark-modal .ant-btn-primary:hover {
background-color: #40a9ff;
border-color: #40a9ff;
}
.dark-modal .ant-btn-text:hover {
background-color: #262626;
}
/* Responsive design */
@media (max-width: 768px) {
.dark-modal .ant-modal {
width: 95% !important;
margin: 10px;
}
.dark-modal .ant-modal-body {
padding: 16px;
}
.status-item {
padding: 12px;
}
}
/* Accessibility improvements */
.drag-handle:focus {
outline: 2px solid #1890ff;
outline-offset: 2px;
}
.dark-modal .drag-handle:focus {
outline-color: #40a9ff;
}
/* Animation for status creation */
.status-item-enter {
opacity: 0;
transform: translateY(-10px);
}
.status-item-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.status-item-exit {
opacity: 1;
transform: translateY(0);
}
.status-item-exit-active {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}

View File

@@ -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;

View File

@@ -0,0 +1,277 @@
/* ManagePhaseModal.css */
/* Dark mode modal styling */
.dark-modal .ant-modal-content {
background-color: #1f1f1f;
border: 1px solid #303030;
}
.dark-modal .ant-modal-header {
background-color: #1f1f1f;
border-bottom: 1px solid #303030;
}
.dark-modal .ant-modal-body {
background-color: #1f1f1f;
}
.dark-modal .ant-modal-footer {
background-color: #1f1f1f;
border-top: 1px solid #303030;
}
.dark-modal .ant-form-item-label > label {
color: #d9d9d9;
}
.dark-modal .ant-input,
.dark-modal .ant-select-selector {
background-color: #141414;
border-color: #303030;
color: #d9d9d9;
}
.dark-modal .ant-input::placeholder {
color: #8c8c8c;
}
.dark-modal .ant-btn-primary {
background-color: #1890ff;
border-color: #1890ff;
}
.dark-modal .ant-btn-primary:hover {
background-color: #40a9ff;
border-color: #40a9ff;
}
.dark-modal .ant-btn-text:hover {
background-color: #262626;
}
/* Color picker styling */
.dark-modal .ant-color-picker-trigger {
background-color: #141414;
border-color: #303030;
}
.dark-modal .ant-color-picker-trigger:hover {
border-color: #40a9ff;
}
.dark-modal .ant-color-picker-panel {
background-color: #1f1f1f;
border-color: #303030;
}
/* Phase management section styling */
.phase-item {
transition: all 0.2s ease;
}
.phase-item:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.dark-modal .phase-item:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
/* Enhanced phase card styling */
.phase-card {
border-radius: 8px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.phase-card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.dark-modal .phase-card:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
}
/* Drag handle styling */
.drag-handle {
cursor: grab;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.drag-handle:hover {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
/* Phase color picker styling */
.phase-color-picker {
border-radius: 6px;
overflow: hidden;
transition: all 0.2s ease;
}
.phase-color-picker:hover {
transform: scale(1.05);
}
.dark-modal .phase-color-picker {
border-color: #303030;
}
.dark-modal .phase-color-picker:hover {
border-color: #40a9ff;
}
/* Improved drag handle */
.drag-handle {
cursor: grab;
opacity: 0.7;
transition: all 0.2s ease;
border-radius: 6px;
}
.drag-handle:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.05);
}
.dark-modal .drag-handle:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.drag-handle:active {
cursor: grabbing;
transform: scale(0.95);
}
/* Phase color dot enhancement */
.phase-color-dot {
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.phase-color-dot:hover {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.dark-modal .phase-color-dot {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.dark-modal .phase-color-dot:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
/* Sortable item styling */
.sortable-phase-item {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.sortable-phase-item.is-dragging {
transform: scale(1.02);
z-index: 999;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
.dark-modal .sortable-phase-item.is-dragging {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
}
/* Phase label input styling */
.phase-label-input {
margin-bottom: 16px;
}
.dark-modal .phase-label-input .ant-input {
background-color: #141414;
border-color: #303030;
color: #d9d9d9;
}
.dark-modal .phase-label-input .ant-input:focus {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(64, 169, 255, 0.2);
}
/* Responsive design */
@media (max-width: 768px) {
.dark-modal .ant-modal {
width: 95% !important;
margin: 10px;
}
.dark-modal .ant-modal-body {
padding: 16px;
}
.phase-item {
padding: 12px;
}
.phase-color-picker {
width: 24px;
height: 24px;
}
}
/* Accessibility improvements */
.drag-handle:focus {
outline: 2px solid #1890ff;
outline-offset: 2px;
}
.dark-modal .drag-handle:focus {
outline-color: #40a9ff;
}
.phase-color-picker:focus {
outline: 2px solid #1890ff;
outline-offset: 2px;
}
.dark-modal .phase-color-picker:focus {
outline-color: #40a9ff;
}
/* Animation for phase creation */
.phase-item-enter {
opacity: 0;
transform: translateY(-10px);
}
.phase-item-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.phase-item-exit {
opacity: 1;
transform: translateY(0);
}
.phase-item-exit-active {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* Loading state styling */
.dark-modal .ant-spin-container {
background-color: transparent;
}
.dark-modal .ant-spin-dot-item {
background-color: #40a9ff;
}
/* Divider styling for dark mode */
.dark-modal .ant-divider-horizontal {
border-color: #303030;
}

View File

@@ -0,0 +1,519 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Modal, Form, Input, Button, Space, Divider, Typography, Flex, ColorPicker } 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 {
addPhaseOption,
fetchPhasesByProjectId,
updatePhaseOrder,
updatePhaseListOrder,
updateProjectPhaseLabel,
updatePhaseName,
deletePhaseOption,
updatePhaseColor,
} from '@/features/projects/singleProject/phase/phases.slice';
import { ITaskPhase } from '@/types/tasks/taskPhase.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 { PhaseColorCodes } from '@/shared/constants';
import './ManagePhaseModal.css';
const { Title, Text } = Typography;
interface ManagePhaseModalProps {
open: boolean;
onClose: () => void;
projectId?: string;
}
interface PhaseItemProps {
phase: ITaskPhase;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
onColorChange: (id: string, color: string) => void;
isDarkMode: boolean;
}
// Sortable Phase Item Component
const SortablePhaseItem: React.FC<PhaseItemProps & { id: string }> = ({
id,
phase,
onRename,
onDelete,
onColorChange,
isDarkMode,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(phase.name || '');
const [color, setColor] = useState(phase.color_code || PhaseColorCodes[0]);
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() !== phase.name) {
onRename(id, editName.trim());
}
setIsEditing(false);
}, [editName, id, onRename, phase.name]);
const handleCancel = useCallback(() => {
setEditName(phase.name || '');
setIsEditing(false);
}, [phase.name]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
handleCancel();
}
}, [handleSave, handleCancel]);
const handleColorChangeComplete = useCallback(() => {
if (color !== phase.color_code) {
onColorChange(id, color);
}
}, [color, id, onColorChange, phase.color_code]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
setColor(phase.color_code || PhaseColorCodes[0]);
}, [phase.color_code]);
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, Phase 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>
{/* Phase 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 phase 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)}
>
{phase.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>
{/* Color Row */}
<div className="flex items-center gap-1.5">
<Text
className={`text-xs font-medium ${
isDarkMode ? 'text-gray-400' : 'text-gray-600'
}`}
>
Color:
</Text>
<ColorPicker
value={color}
onChange={(value) => setColor(value.toHexString())}
onChangeComplete={handleColorChangeComplete}
size="small"
className="phase-color-picker"
/>
<div
className="w-3 h-3 rounded-full border ml-1 transition-all duration-200"
style={{
backgroundColor: color,
borderColor: isDarkMode ? '#374151' : '#e5e7eb',
}}
/>
</div>
</div>
);
};
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);
const [isSaving, setIsSaving] = useState(false);
const finalProjectId = projectId || currentProjectId;
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
useEffect(() => {
if (open && finalProjectId) {
dispatch(fetchPhasesByProjectId(finalProjectId));
setPhaseName(project?.phase_label || '');
setInitialPhaseName(project?.phase_label || '');
}
}, [open, finalProjectId, project?.phase_label, dispatch]);
const refreshTasks = useCallback(async () => {
if (finalProjectId) {
await dispatch(fetchTasksV3(finalProjectId));
await dispatch(fetchEnhancedKanbanGroups(finalProjectId));
}
}, [finalProjectId, dispatch]);
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);
const newPhaseList = [...phaseList];
const [movedItem] = newPhaseList.splice(oldIndex, 1);
newPhaseList.splice(newIndex, 0, movedItem);
try {
setSorting(true);
dispatch(updatePhaseListOrder(newPhaseList));
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);
}
}
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
const handleAddPhase = useCallback(async () => {
if (!finalProjectId) return;
try {
await dispatch(addPhaseOption({ projectId: finalProjectId }));
await dispatch(fetchPhasesByProjectId(finalProjectId));
await refreshTasks();
} catch (error) {
console.error('Error adding phase:', error);
}
}, [finalProjectId, dispatch, refreshTasks]);
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();
}
} catch (error) {
console.error('Error renaming phase:', error);
}
}, [finalProjectId, phaseList, dispatch, refreshTasks]);
const handleDeletePhase = useCallback(async (id: string) => {
if (!finalProjectId) return;
AntModal.confirm({
title: 'Delete Phase',
content: 'Are you sure you want to delete this phase? This action cannot be undone.',
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]);
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) {
setInitialPhaseName(phaseName);
await refreshTasks();
}
} catch (error) {
console.error('Error updating phase name:', error);
} finally {
setIsSaving(false);
}
}, [finalProjectId, phaseName, initialPhaseName, dispatch, refreshTasks]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
return (
<Modal
title={
<Title level={4} className={isDarkMode ? 'text-gray-200' : 'text-gray-800'}>
{t('configurePhases')}
</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' : ''}
loading={loadingPhases || sorting}
>
<div className="space-y-4">
{/* Phase Label Configuration */}
<div className={`p-3 rounded-md border ${
isDarkMode
? 'bg-gray-800/30 border-gray-700'
: 'bg-blue-50/50 border-blue-200'
}`}>
<div className="space-y-2">
<Text className={`text-xs font-medium ${
isDarkMode ? 'text-gray-300' : 'text-gray-700'
}`}>
{t('phaseLabel')}
</Text>
<Input
placeholder={t('enterPhaseName')}
value={phaseName}
onChange={e => setPhaseName(e.currentTarget.value)}
onPressEnter={handlePhaseNameBlur}
onBlur={handlePhaseNameBlur}
disabled={isSaving}
size="small"
/>
</div>
</div>
<Divider className={isDarkMode ? 'border-gray-700' : 'border-gray-300'} />
{/* Phase Options */}
<div className="space-y-4">
<div className={`p-2.5 rounded-md ${
isDarkMode ? 'bg-gray-800/50' : 'bg-purple-50'
}`}>
<Text
type="secondary"
className={`text-xs ${
isDarkMode ? 'text-gray-400' : 'text-purple-600'
}`}
>
🎨 Drag phases to reorder them. Each phase can have a custom color.
</Text>
</div>
{/* Add New Phase */}
<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">
<Text className={`text-xs font-medium flex-shrink-0 self-center ${
isDarkMode ? 'text-gray-300' : 'text-gray-700'
}`}>
{t('phaseOptions')}:
</Text>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddPhase}
disabled={loadingPhases}
size="small"
className="ml-auto"
>
{t('addOption')}
</Button>
</div>
</div>
{/* Phase List with Drag & Drop */}
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={phaseList.map(phase => phase.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{phaseList.map((phase) => (
<SortablePhaseItem
key={phase.id}
id={phase.id}
phase={phase}
onRename={handleRenamePhase}
onDelete={handleDeletePhase}
onColorChange={handleColorChange}
isDarkMode={isDarkMode}
/>
))}
</div>
</SortableContext>
</DndContext>
{phaseList.length === 0 && (
<div className={`text-center py-8 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
<Text>No phases found. Add your first phase above.</Text>
</div>
)}
</div>
</div>
</Modal>
);
};
export default ManagePhaseModal;

View File

@@ -0,0 +1,249 @@
/* ManageStatusModal.css */
/* Dark mode modal styling */
.dark-modal .ant-modal-content {
background-color: #1f1f1f;
border: 1px solid #303030;
}
.dark-modal .ant-modal-header {
background-color: #1f1f1f;
border-bottom: 1px solid #303030;
}
.dark-modal .ant-modal-body {
background-color: #1f1f1f;
}
.dark-modal .ant-modal-footer {
background-color: #1f1f1f;
border-top: 1px solid #303030;
}
.dark-modal .ant-form-item-label > label {
color: #d9d9d9;
}
.dark-modal .ant-input,
.dark-modal .ant-select-selector {
background-color: #141414;
border-color: #303030;
color: #d9d9d9;
}
.dark-modal .ant-select-dropdown {
background-color: #1f1f1f;
border-color: #303030;
}
.dark-modal .ant-select-item {
color: #d9d9d9;
}
.dark-modal .ant-select-item:hover {
background-color: #262626;
}
.dark-modal .ant-select-item-option-selected {
background-color: #1890ff;
color: white;
}
/* Category select styling */
.category-select .ant-select-selector {
height: 28px !important;
min-height: 28px !important;
border-radius: 6px !important;
transition: all 0.2s ease !important;
}
.category-select:hover .ant-select-selector {
border-color: #40a9ff !important;
}
.dark-modal .category-select .ant-select-selector {
background-color: #141414;
border-color: #303030;
color: #d9d9d9;
}
.dark-modal .category-select:hover .ant-select-selector {
border-color: #40a9ff !important;
background-color: #1a1a1a;
}
/* Improved drag handle */
.drag-handle {
cursor: grab;
opacity: 0.7;
transition: all 0.2s ease;
border-radius: 6px;
}
.drag-handle:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.05);
}
.dark-modal .drag-handle:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.drag-handle:active {
cursor: grabbing;
transform: scale(0.95);
}
/* Status color dot enhancement */
.status-color-dot {
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.status-color-dot:hover {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.dark-modal .status-color-dot {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.dark-modal .status-color-dot:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
.dark-modal .ant-input::placeholder {
color: #8c8c8c;
}
.dark-modal .ant-btn-primary {
background-color: #1890ff;
border-color: #1890ff;
}
.dark-modal .ant-btn-primary:hover {
background-color: #40a9ff;
border-color: #40a9ff;
}
.dark-modal .ant-btn-text:hover {
background-color: #262626;
}
/* Status management section styling */
.status-item {
transition: all 0.2s ease;
}
.status-item:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.dark-modal .status-item:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
/* Enhanced status card styling */
.status-card {
border-radius: 8px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.status-card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.dark-modal .status-card:hover {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
}
/* Drag handle styling */
.drag-handle {
cursor: grab;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.drag-handle:hover {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
/* Status color indicator */
.status-color {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.dark-modal .status-color {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
/* Sortable item styling */
.sortable-status-item {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.sortable-status-item.is-dragging {
transform: scale(1.02);
z-index: 999;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
.dark-modal .sortable-status-item.is-dragging {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4);
}
/* Responsive design */
@media (max-width: 768px) {
.dark-modal .ant-modal {
width: 95% !important;
margin: 10px;
}
.dark-modal .ant-modal-body {
padding: 16px;
}
.status-item {
padding: 12px;
}
}
/* Accessibility improvements */
.drag-handle:focus {
outline: 2px solid #1890ff;
outline-offset: 2px;
}
.dark-modal .drag-handle:focus {
outline-color: #40a9ff;
}
/* Animation for status creation */
.status-item-enter {
opacity: 0;
transform: translateY(-10px);
}
.status-item-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.status-item-exit {
opacity: 1;
transform: translateY(0);
}
.status-item-exit-active {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}

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;

View File

@@ -73,9 +73,9 @@ import {
setBoardLabels,
} from '@/features/board/board-slice';
// Import ConfigPhaseButton and CreateStatusButton components
import ConfigPhaseButton from '@/features/projects/singleProject/phase/ConfigPhaseButton';
import CreateStatusButton from '@/components/project-task-filters/create-status-button/create-status-button';
// Import modal components
import ManageStatusModal from '@/components/task-management/ManageStatusModal';
import ManagePhaseModal from '@/components/task-management/ManagePhaseModal';
import { useAuthService } from '@/hooks/useAuth';
import useIsProjectManager from '@/hooks/useIsProjectManager';
@@ -367,6 +367,8 @@ const FilterDropdown: React.FC<{
isDarkMode: boolean;
className?: string;
dispatch?: any;
onManageStatus?: () => void;
onManagePhase?: () => void;
}> = ({
section,
onSelectionChange,
@@ -376,6 +378,8 @@ const FilterDropdown: React.FC<{
isDarkMode,
className = '',
dispatch,
onManageStatus,
onManagePhase,
}) => {
const { t } = useTranslation('task-list-filters');
// Add permission checks for groupBy section
@@ -486,11 +490,7 @@ const FilterDropdown: React.FC<{
<div className="inline-flex items-center gap-1 ml-2">
{section.selectedValues[0] === 'phase' && (
<button
onClick={() => {
import('@/features/projects/singleProject/phase/phases.slice').then(({ toggleDrawer }) => {
dispatch(toggleDrawer());
});
}}
onClick={onManagePhase}
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 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
}`}
@@ -500,11 +500,7 @@ const FilterDropdown: React.FC<{
)}
{section.selectedValues[0] === 'status' && (
<button
onClick={() => {
import('@/features/projects/status/StatusSlice').then(({ toggleDrawer }) => {
dispatch(toggleDrawer());
});
}}
onClick={onManageStatus}
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 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
}`}
@@ -946,7 +942,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
const showArchived = position === 'list' ? taskManagementArchived : taskReducerArchived;
// Use the filter data loader hook
useFilterDataLoader();
const { refreshFilterData } = useFilterDataLoader();
// Get search value from Redux based on position
const taskManagementSearch = useAppSelector(state => state.taskManagement?.search || '');
@@ -959,6 +955,10 @@ 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);
// Refs for debounced functions
const debouncedFilterChangeRef = useRef<
@@ -1296,6 +1296,8 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
themeClasses={themeClasses}
isDarkMode={isDarkMode}
dispatch={dispatch}
onManageStatus={() => setShowManageStatusModal(true)}
onManagePhase={() => setShowManagePhaseModal(true)}
/>
))
) : (
@@ -1356,6 +1358,27 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
)}
</div>
</div>
{/* Modals */}
<ManageStatusModal
open={showManageStatusModal}
onClose={() => {
setShowManageStatusModal(false);
// Refresh filter data after status changes
refreshFilterData();
}}
projectId={projectId || undefined}
/>
<ManagePhaseModal
open={showManagePhaseModal}
onClose={() => {
setShowManagePhaseModal(false);
// Refresh filter data after phase changes
refreshFilterData();
}}
projectId={projectId || undefined}
/>
</div>
);
};