diff --git a/worklenz-frontend/public/locales/alb/phases-drawer.json b/worklenz-frontend/public/locales/alb/phases-drawer.json index de34c740..cccda7d2 100644 --- a/worklenz-frontend/public/locales/alb/phases-drawer.json +++ b/worklenz-frontend/public/locales/alb/phases-drawer.json @@ -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" } diff --git a/worklenz-frontend/public/locales/alb/task-list-filters.json b/worklenz-frontend/public/locales/alb/task-list-filters.json index bfb76191..c3156498 100644 --- a/worklenz-frontend/public/locales/alb/task-list-filters.json +++ b/worklenz-frontend/public/locales/alb/task-list-filters.json @@ -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" } diff --git a/worklenz-frontend/public/locales/de/phases-drawer.json b/worklenz-frontend/public/locales/de/phases-drawer.json index d06e3d05..c9e41e09 100644 --- a/worklenz-frontend/public/locales/de/phases-drawer.json +++ b/worklenz-frontend/public/locales/de/phases-drawer.json @@ -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" } diff --git a/worklenz-frontend/public/locales/de/task-list-filters.json b/worklenz-frontend/public/locales/de/task-list-filters.json index 8c08037f..0854c34f 100644 --- a/worklenz-frontend/public/locales/de/task-list-filters.json +++ b/worklenz-frontend/public/locales/de/task-list-filters.json @@ -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" } diff --git a/worklenz-frontend/public/locales/en/phases-drawer.json b/worklenz-frontend/public/locales/en/phases-drawer.json index ca870b8f..10ad78a4 100644 --- a/worklenz-frontend/public/locales/en/phases-drawer.json +++ b/worklenz-frontend/public/locales/en/phases-drawer.json @@ -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" } diff --git a/worklenz-frontend/public/locales/en/task-list-filters.json b/worklenz-frontend/public/locales/en/task-list-filters.json index cdfa00e9..a38356c6 100644 --- a/worklenz-frontend/public/locales/en/task-list-filters.json +++ b/worklenz-frontend/public/locales/en/task-list-filters.json @@ -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" } diff --git a/worklenz-frontend/public/locales/es/phases-drawer.json b/worklenz-frontend/public/locales/es/phases-drawer.json index 6339389a..e961b068 100644 --- a/worklenz-frontend/public/locales/es/phases-drawer.json +++ b/worklenz-frontend/public/locales/es/phases-drawer.json @@ -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" } diff --git a/worklenz-frontend/public/locales/es/task-list-filters.json b/worklenz-frontend/public/locales/es/task-list-filters.json index 39aeadb0..465368f0 100644 --- a/worklenz-frontend/public/locales/es/task-list-filters.json +++ b/worklenz-frontend/public/locales/es/task-list-filters.json @@ -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" } diff --git a/worklenz-frontend/public/locales/pt/phases-drawer.json b/worklenz-frontend/public/locales/pt/phases-drawer.json index 6339389a..080b13df 100644 --- a/worklenz-frontend/public/locales/pt/phases-drawer.json +++ b/worklenz-frontend/public/locales/pt/phases-drawer.json @@ -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" } diff --git a/worklenz-frontend/public/locales/pt/task-list-filters.json b/worklenz-frontend/public/locales/pt/task-list-filters.json index 0bd8bc31..21e8806b 100644 --- a/worklenz-frontend/public/locales/pt/task-list-filters.json +++ b/worklenz-frontend/public/locales/pt/task-list-filters.json @@ -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" } diff --git a/worklenz-frontend/public/locales/zh/phases-drawer.json b/worklenz-frontend/public/locales/zh/phases-drawer.json index 4bfb2a13..24d21b38 100644 --- a/worklenz-frontend/public/locales/zh/phases-drawer.json +++ b/worklenz-frontend/public/locales/zh/phases-drawer.json @@ -3,5 +3,17 @@ "phaseLabel": "阶段标签", "enterPhaseName": "输入阶段标签名称", "addOption": "添加选项", - "phaseOptions": "阶段选项:" + "phaseOptions": "阶段选项:", + "dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。", + "enterNewPhaseName": "输入新阶段名称...", + "addPhase": "添加阶段", + "noPhasesFound": "未找到阶段。请在上面创建您的第一个阶段。", + "deletePhase": "删除阶段", + "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。", + "rename": "重命名", + "delete": "删除", + "enterPhaseName": "输入阶段名称", + "selectColor": "选择颜色", + "managePhases": "管理阶段", + "close": "关闭" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index f6a50e1e..84387509 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -64,5 +64,16 @@ "search": "搜索", "groupedBy": "分组依据", "manageStatuses": "管理状态", - "managePhases": "管理阶段" + "managePhases": "管理阶段", + "dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。", + "enterNewStatusName": "输入新状态名称...", + "addStatus": "添加状态", + "noStatusesFound": "未找到状态。请在上面创建您的第一个状态。", + "deleteStatus": "删除状态", + "deleteStatusConfirm": "您确定要删除此状态吗?此操作无法撤销。", + "rename": "重命名", + "delete": "删除", + "enterStatusName": "输入状态名称", + "selectCategory": "选择类别", + "close": "关闭" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/CreateTaskModal.css b/worklenz-frontend/src/components/task-management/CreateTaskModal.css new file mode 100644 index 00000000..481e986c --- /dev/null +++ b/worklenz-frontend/src/components/task-management/CreateTaskModal.css @@ -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; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx b/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx new file mode 100644 index 00000000..c69aad5d --- /dev/null +++ b/worklenz-frontend/src/components/task-management/CreateTaskModal.tsx @@ -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 = ({ + id, + status, + onRename, + onDelete, + isDarkMode, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(status.name || ''); + const inputRef = useRef(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 ( +
+ {/* Drag Handle */} +
+ +
+ + {/* Status Color */} +
+ + {/* Status Name */} +
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + size="small" + className="font-medium" + /> + ) : ( + setIsEditing(true)} + > + {status.name} + + )} +
+ + {/* Actions */} + + + + +
+ ); +}; + +// 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 ( +
+
+ + {t('manageStatuses')} + + + Drag to reorder + +
+ + {/* Create New Status */} +
+ setNewStatusName(e.target.value)} + onPressEnter={handleCreateStatus} + className="flex-1" + /> + +
+ + + + {/* Status List with Drag & Drop */} + + status.id).map(status => status.id as string)} + strategy={verticalListSortingStrategy} + > +
+ {localStatuses.filter(status => status.id).map((status) => ( + + ))} +
+
+
+ + {localStatuses.length === 0 && ( +
+ No statuses found. Create your first status above. +
+ )} +
+ ); +}; + +const CreateTaskModal: React.FC = ({ + 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 ( + + {t('createTask')} + + } + open={open} + onCancel={handleCancel} + width={800} + style={{ top: 20 }} + bodyStyle={{ + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '24px', + }} + footer={ + +
+ + + + +
+ } + className={isDarkMode ? 'dark-modal' : ''} + > + +
+ + + + + + + + + {/* Status Selection */} + + + + + {/* Priority Selection */} + + + + + {/* Assignees */} + + + + + {/* Due Date */} + + + + +
+
+ ), + }, + { + key: 'status-management', + label: t('manageStatuses'), + children: finalProjectId ? ( +
+ +
+ ) : ( +
+ Project ID is required for status management. +
+ ), + }, + ]} + /> + + ); +}; + +export default CreateTaskModal; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.css b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css new file mode 100644 index 00000000..e36bcd73 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css @@ -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; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx new file mode 100644 index 00000000..0e10d437 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx @@ -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 = ({ + 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(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 ( +
+ {/* Header Row - Drag Handle, Phase Name, Actions */} +
+ {/* Drag Handle */} +
+ +
+ + {/* Phase Name */} +
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="font-medium" + placeholder="Enter phase name" + /> + ) : ( + setIsEditing(true)} + > + {phase.name} + + )} +
+ + {/* Actions */} + + + + +
+ + {/* Color Row */} +
+ + Color: + + setColor(value.toHexString())} + onChangeComplete={handleColorChangeComplete} + size="small" + className="phase-color-picker" + /> +
+
+
+ ); +}; + +const ManagePhaseModal: React.FC = ({ + 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(project?.phase_label || ''); + const [initialPhaseName, setInitialPhaseName] = useState(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 ( + + {t('configurePhases')} + + } + open={open} + onCancel={handleClose} + width={600} + style={{ top: 20 }} + bodyStyle={{ + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '24px', + }} + footer={ + + + + } + className={isDarkMode ? 'dark-modal' : ''} + loading={loadingPhases || sorting} + > +
+ {/* Phase Label Configuration */} +
+
+ + {t('phaseLabel')} + + setPhaseName(e.currentTarget.value)} + onPressEnter={handlePhaseNameBlur} + onBlur={handlePhaseNameBlur} + disabled={isSaving} + size="small" + /> +
+
+ + + + {/* Phase Options */} +
+
+ + 🎨 Drag phases to reorder them. Each phase can have a custom color. + +
+ + {/* Add New Phase */} +
+
+ + {t('phaseOptions')}: + + +
+
+ + {/* Phase List with Drag & Drop */} + + phase.id)} + strategy={verticalListSortingStrategy} + > +
+ {phaseList.map((phase) => ( + + ))} +
+
+
+ + {phaseList.length === 0 && ( +
+ No phases found. Add your first phase above. +
+ )} +
+
+
+ ); +}; + +export default ManagePhaseModal; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManageStatusModal.css b/worklenz-frontend/src/components/task-management/ManageStatusModal.css new file mode 100644 index 00000000..69ee4379 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManageStatusModal.css @@ -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; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx new file mode 100644 index 00000000..054ecc88 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx @@ -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 = ({ + id, + status, + onRename, + onDelete, + onCategoryChange, + isDarkMode, + categories, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(status.name || ''); + const inputRef = useRef(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 ( +
+ {/* Header Row - Drag Handle, Color, Name, Actions */} +
+ {/* Drag Handle */} +
+ +
+ + {/* Status Color */} +
+ + {/* Status Name */} +
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="font-medium" + placeholder="Enter status name" + /> + ) : ( + setIsEditing(true)} + > + {status.name} + + )} +
+ + {/* Actions */} + + + + +
+ + {/* Category Row */} +
+ + Category: + + +
+
+ ); +}; + +const ManageStatusModal: React.FC = ({ + 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([]); + + 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 ( + + {t('manageStatuses')} + + } + open={open} + onCancel={handleClose} + width={600} + style={{ top: 20 }} + bodyStyle={{ + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + padding: '24px', + }} + footer={ + + + + } + className={isDarkMode ? 'dark-modal' : ''} + > +
+
+ + 💡 Drag statuses to reorder them. Each status can have a different category. + +
+ + {/* Create New Status */} +
+
+ setNewStatusName(e.target.value)} + onPressEnter={handleCreateStatus} + className="flex-1" + size="small" + /> + +
+
+ + + + {/* Status List with Drag & Drop */} + + status.id).map(status => status.id as string)} + strategy={verticalListSortingStrategy} + > +
+ {localStatuses.filter(status => status.id).map((status) => ( + + ))} +
+
+
+ + {localStatuses.length === 0 && ( +
+ No statuses found. Create your first status above. +
+ )} +
+
+ ); +}; + +export default ManageStatusModal; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index 6e6669c0..c913d6e9 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -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<{
{section.selectedValues[0] === 'phase' && (
+ + {/* Modals */} + { + setShowManageStatusModal(false); + // Refresh filter data after status changes + refreshFilterData(); + }} + projectId={projectId || undefined} + /> + + { + setShowManagePhaseModal(false); + // Refresh filter data after phase changes + refreshFilterData(); + }} + projectId={projectId || undefined} + />
); }; diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts index 6165e018..9972b9eb 100644 --- a/worklenz-frontend/src/hooks/useFilterDataLoader.ts +++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts @@ -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, }; };