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:
@@ -3,5 +3,17 @@
|
||||
"phaseLabel": "Etiketa e Fazës",
|
||||
"enterPhaseName": "Vendosni një emër për etiketën e fazës",
|
||||
"addOption": "Shto Opsion",
|
||||
"phaseOptions": "Opsionet e Fazës:"
|
||||
"phaseOptions": "Opsionet e Fazës:",
|
||||
"dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.",
|
||||
"enterNewPhaseName": "Shkruani emrin e fazës së re...",
|
||||
"addPhase": "Shto Fazë",
|
||||
"noPhasesFound": "Nuk u gjetën faza. Krijoni fazën tuaj të parë më sipër.",
|
||||
"deletePhase": "Fshi Fazën",
|
||||
"deletePhaseConfirm": "Jeni të sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.",
|
||||
"rename": "Riemëro",
|
||||
"delete": "Fshi",
|
||||
"enterPhaseName": "Shkruani emrin e fazës",
|
||||
"selectColor": "Zgjidh ngjyrën",
|
||||
"managePhases": "Menaxho Fazat",
|
||||
"close": "Mbyll"
|
||||
}
|
||||
|
||||
@@ -70,5 +70,16 @@
|
||||
"search": "Kërko",
|
||||
"groupedBy": "Grupuar sipas",
|
||||
"manageStatuses": "Menaxho Statuset",
|
||||
"managePhases": "Menaxho Fazat"
|
||||
"managePhases": "Menaxho Fazat",
|
||||
"dragToReorderStatuses": "Zvarrit statuset për t'i rirenditur. Çdo status mund të ketë një kategori të ndryshme.",
|
||||
"enterNewStatusName": "Shkruani emrin e statusit të ri...",
|
||||
"addStatus": "Shto Status",
|
||||
"noStatusesFound": "Nuk u gjetën statuse. Krijoni statusin tuaj të parë më sipër.",
|
||||
"deleteStatus": "Fshi Statusin",
|
||||
"deleteStatusConfirm": "Jeni të sigurt që doni të fshini këtë status? Ky veprim nuk mund të zhbëhet.",
|
||||
"rename": "Riemëro",
|
||||
"delete": "Fshi",
|
||||
"enterStatusName": "Shkruani emrin e statusit",
|
||||
"selectCategory": "Zgjidh kategorinë",
|
||||
"close": "Mbyll"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,17 @@
|
||||
"phaseLabel": "Phasenbezeichnung",
|
||||
"enterPhaseName": "Namen für Phasenbezeichnung eingeben",
|
||||
"addOption": "Option hinzufügen",
|
||||
"phaseOptions": "Phasenoptionen:"
|
||||
"phaseOptions": "Phasenoptionen:",
|
||||
"dragToReorderPhases": "Ziehen Sie Phasen, um sie neu zu ordnen. Jede Phase kann eine andere Farbe haben.",
|
||||
"enterNewPhaseName": "Neuen Phasennamen eingeben...",
|
||||
"addPhase": "Phase hinzufügen",
|
||||
"noPhasesFound": "Keine Phasen gefunden. Erstellen Sie Ihre erste Phase oben.",
|
||||
"deletePhase": "Phase löschen",
|
||||
"deletePhaseConfirm": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"rename": "Umbenennen",
|
||||
"delete": "Löschen",
|
||||
"enterPhaseName": "Phasennamen eingeben",
|
||||
"selectColor": "Farbe auswählen",
|
||||
"managePhases": "Phasen verwalten",
|
||||
"close": "Schließen"
|
||||
}
|
||||
|
||||
@@ -70,5 +70,16 @@
|
||||
"search": "Suchen",
|
||||
"groupedBy": "Gruppiert nach",
|
||||
"manageStatuses": "Status verwalten",
|
||||
"managePhases": "Phasen verwalten"
|
||||
"managePhases": "Phasen verwalten",
|
||||
"dragToReorderStatuses": "Ziehen Sie Status, um sie neu zu ordnen. Jeder Status kann eine andere Kategorie haben.",
|
||||
"enterNewStatusName": "Neuen Statusnamen eingeben...",
|
||||
"addStatus": "Status hinzufügen",
|
||||
"noStatusesFound": "Keine Status gefunden. Erstellen Sie Ihren ersten Status oben.",
|
||||
"deleteStatus": "Status löschen",
|
||||
"deleteStatusConfirm": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"rename": "Umbenennen",
|
||||
"delete": "Löschen",
|
||||
"enterStatusName": "Statusnamen eingeben",
|
||||
"selectCategory": "Kategorie auswählen",
|
||||
"close": "Schließen"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,17 @@
|
||||
"phaseLabel": "Phase Label",
|
||||
"enterPhaseName": "Enter a name for phase label",
|
||||
"addOption": "Add Option",
|
||||
"phaseOptions": "Phase Options:"
|
||||
"phaseOptions": "Phase Options:",
|
||||
"dragToReorderPhases": "Drag phases to reorder them. Each phase can have a different color.",
|
||||
"enterNewPhaseName": "Enter new phase name...",
|
||||
"addPhase": "Add Phase",
|
||||
"noPhasesFound": "No phases found. Create your first phase above.",
|
||||
"deletePhase": "Delete Phase",
|
||||
"deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"enterPhaseName": "Enter phase name",
|
||||
"selectColor": "Select color",
|
||||
"managePhases": "Manage Phases",
|
||||
"close": "Close"
|
||||
}
|
||||
|
||||
@@ -70,5 +70,16 @@
|
||||
"search": "Search",
|
||||
"groupedBy": "Grouped by",
|
||||
"manageStatuses": "Manage Statuses",
|
||||
"managePhases": "Manage Phases"
|
||||
"managePhases": "Manage Phases",
|
||||
"dragToReorderStatuses": "Drag statuses to reorder them. Each status can have a different category.",
|
||||
"enterNewStatusName": "Enter new status name...",
|
||||
"addStatus": "Add Status",
|
||||
"noStatusesFound": "No statuses found. Create your first status above.",
|
||||
"deleteStatus": "Delete Status",
|
||||
"deleteStatusConfirm": "Are you sure you want to delete this status? This action cannot be undone.",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"enterStatusName": "Enter status name",
|
||||
"selectCategory": "Select category",
|
||||
"close": "Close"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,17 @@
|
||||
"phaseLabel": "Etiqueta de fase",
|
||||
"enterPhaseName": "Ingrese un nombre para la etiqueta de fase",
|
||||
"addOption": "Agregar opción",
|
||||
"phaseOptions": "Opciones de fase:"
|
||||
"phaseOptions": "Opciones de fase:",
|
||||
"dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.",
|
||||
"enterNewPhaseName": "Introducir nuevo nombre de fase...",
|
||||
"addPhase": "Añadir Fase",
|
||||
"noPhasesFound": "No se encontraron fases. Crea tu primera fase arriba.",
|
||||
"deletePhase": "Eliminar Fase",
|
||||
"deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.",
|
||||
"rename": "Renombrar",
|
||||
"delete": "Eliminar",
|
||||
"enterPhaseName": "Introducir nombre de la fase",
|
||||
"selectColor": "Seleccionar color",
|
||||
"managePhases": "Gestionar Fases",
|
||||
"close": "Cerrar"
|
||||
}
|
||||
|
||||
@@ -66,5 +66,16 @@
|
||||
"search": "Buscar",
|
||||
"groupedBy": "Agrupado por",
|
||||
"manageStatuses": "Gestionar Estados",
|
||||
"managePhases": "Gestionar Fases"
|
||||
"managePhases": "Gestionar Fases",
|
||||
"dragToReorderStatuses": "Arrastra los estados para reordenarlos. Cada estado puede tener una categoría diferente.",
|
||||
"enterNewStatusName": "Introducir nuevo nombre de estado...",
|
||||
"addStatus": "Añadir Estado",
|
||||
"noStatusesFound": "No se encontraron estados. Crea tu primer estado arriba.",
|
||||
"deleteStatus": "Eliminar Estado",
|
||||
"deleteStatusConfirm": "¿Estás seguro de que quieres eliminar este estado? Esta acción no se puede deshacer.",
|
||||
"rename": "Renombrar",
|
||||
"delete": "Eliminar",
|
||||
"enterStatusName": "Introducir nombre del estado",
|
||||
"selectCategory": "Seleccionar categoría",
|
||||
"close": "Cerrar"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{
|
||||
"configurePhases": "Configurar fases",
|
||||
"phaseLabel": "Etiqueta de fase",
|
||||
"enterPhaseName": "Ingrese un nombre para la etiqueta de fase",
|
||||
"addOption": "Agregar opción",
|
||||
"phaseOptions": "Opciones de fase:"
|
||||
"enterPhaseName": "Digite um nome para o rótulo da fase",
|
||||
"addOption": "Adicionar Opção",
|
||||
"phaseOptions": "Opções de Fase:",
|
||||
"dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.",
|
||||
"enterNewPhaseName": "Digite o novo nome da fase...",
|
||||
"addPhase": "Adicionar Fase",
|
||||
"noPhasesFound": "Nenhuma fase encontrada. Crie sua primeira fase acima.",
|
||||
"deletePhase": "Excluir Fase",
|
||||
"deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.",
|
||||
"rename": "Renomear",
|
||||
"delete": "Excluir",
|
||||
"enterPhaseName": "Digite o nome da fase",
|
||||
"selectColor": "Selecionar cor",
|
||||
"managePhases": "Gerenciar Fases",
|
||||
"close": "Fechar"
|
||||
}
|
||||
|
||||
@@ -67,5 +67,16 @@
|
||||
"search": "Pesquisar",
|
||||
"groupedBy": "Agrupado por",
|
||||
"manageStatuses": "Gerenciar Status",
|
||||
"managePhases": "Gerenciar Fases"
|
||||
"managePhases": "Gerenciar Fases",
|
||||
"dragToReorderStatuses": "Arraste os status para reordená-los. Cada status pode ter uma categoria diferente.",
|
||||
"enterNewStatusName": "Digite o novo nome do status...",
|
||||
"addStatus": "Adicionar Status",
|
||||
"noStatusesFound": "Nenhum status encontrado. Crie seu primeiro status acima.",
|
||||
"deleteStatus": "Excluir Status",
|
||||
"deleteStatusConfirm": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.",
|
||||
"rename": "Renomear",
|
||||
"delete": "Excluir",
|
||||
"enterStatusName": "Digite o nome do status",
|
||||
"selectCategory": "Selecionar categoria",
|
||||
"close": "Fechar"
|
||||
}
|
||||
|
||||
@@ -3,5 +3,17 @@
|
||||
"phaseLabel": "阶段标签",
|
||||
"enterPhaseName": "输入阶段标签名称",
|
||||
"addOption": "添加选项",
|
||||
"phaseOptions": "阶段选项:"
|
||||
"phaseOptions": "阶段选项:",
|
||||
"dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。",
|
||||
"enterNewPhaseName": "输入新阶段名称...",
|
||||
"addPhase": "添加阶段",
|
||||
"noPhasesFound": "未找到阶段。请在上面创建您的第一个阶段。",
|
||||
"deletePhase": "删除阶段",
|
||||
"deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。",
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
"enterPhaseName": "输入阶段名称",
|
||||
"selectColor": "选择颜色",
|
||||
"managePhases": "管理阶段",
|
||||
"close": "关闭"
|
||||
}
|
||||
@@ -64,5 +64,16 @@
|
||||
"search": "搜索",
|
||||
"groupedBy": "分组依据",
|
||||
"manageStatuses": "管理状态",
|
||||
"managePhases": "管理阶段"
|
||||
"managePhases": "管理阶段",
|
||||
"dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。",
|
||||
"enterNewStatusName": "输入新状态名称...",
|
||||
"addStatus": "添加状态",
|
||||
"noStatusesFound": "未找到状态。请在上面创建您的第一个状态。",
|
||||
"deleteStatus": "删除状态",
|
||||
"deleteStatusConfirm": "您确定要删除此状态吗?此操作无法撤销。",
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
"enterStatusName": "输入状态名称",
|
||||
"selectCategory": "选择类别",
|
||||
"close": "关闭"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { fetchLabelsByProject, fetchTaskAssignees } from '@/features/tasks/tasks.slice';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
|
||||
/**
|
||||
* Hook to manage filter data loading independently of main task list loading
|
||||
@@ -22,6 +23,27 @@ export const useFilterDataLoader = () => {
|
||||
// Memoize the projectId selector to prevent unnecessary re-renders
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
// Export a refresh function for external use
|
||||
const refreshFilterData = useCallback(async () => {
|
||||
if (projectId) {
|
||||
dispatch(fetchLabelsByProject(projectId));
|
||||
dispatch(fetchTaskAssignees(projectId));
|
||||
dispatch(fetchStatuses(projectId));
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
dispatch(fetchPriorities());
|
||||
dispatch(
|
||||
getTeamMembers({
|
||||
index: 0,
|
||||
size: 100,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: true,
|
||||
})
|
||||
);
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
// Load filter data asynchronously
|
||||
const loadFilterData = useCallback(async () => {
|
||||
try {
|
||||
@@ -71,5 +93,6 @@ export const useFilterDataLoader = () => {
|
||||
|
||||
return {
|
||||
loadFilterData,
|
||||
refreshFilterData,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user