Merge branch 'main' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1.2.0

This commit is contained in:
shancds
2025-07-15 09:00:38 +05:30
63 changed files with 2190 additions and 753 deletions

View File

@@ -1,16 +1,20 @@
{
"configurePhases": "Konfiguro Fazat",
"configure": "Konfiguro",
"phaseLabel": "Etiketa e Fazës",
"enterPhaseName": "Shkruani emrin e fazës",
"enterPhaseName": "Shkruaj emrin e fazës",
"addOption": "Shto Opsion",
"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...",
"optionsText": "Opsione",
"dragToReorderPhases": "Tërhiq fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.",
"enterNewPhaseName": "Shkruaj emrin e fazës së re...",
"addPhase": "Shto Fazë",
"noPhasesFound": "Nuk u gjetën faza",
"no": "Asnjë",
"found": "u gjet",
"deletePhase": "Fshi Fazën",
"deletePhaseConfirm": "Jeni sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.",
"rename": "Riemëro",
"deletePhaseConfirm": "Jeni i sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.",
"rename": "Riemërto",
"delete": "Fshi",
"create": "Krijo",
"cancel": "Anulo",

View File

@@ -1,30 +1,31 @@
{
"importTasks": "Importo detyra",
"importTask": "Importo detyrë",
"importTasks": "Importo detyrat",
"importTask": "Importo detyrën",
"createTask": "Krijo detyrë",
"settings": "Cilësimet",
"subscribe": "Abonohu",
"unsubscribe": "Çabonohu",
"deleteProject": "Fshi projektin",
"deleteProject": "Fshij projektin",
"startDate": "Data e fillimit",
"endDate": "Data e mbarimit",
"endDate": "Data e përfundimit",
"projectSettings": "Cilësimet e projektit",
"projectSummary": "Përmbledhja e projektit",
"receiveProjectSummary": "Merrni një përmbledhje të projektit çdo mbrëmje.",
"receiveProjectSummary": "Merr një përmbledhje të projektit çdo mbrëmje.",
"refreshProject": "Rifresko projektin",
"saveAsTemplate": "Ruaj si model",
"saveAsTemplate": "Ruaj si shabllon",
"invite": "Fto",
"share": "Ndaj",
"subscribeTooltip": "Abonohu tek njoftimet e projektit",
"subscribeTooltip": "Abonohu njoftimet e projektit",
"unsubscribeTooltip": "Çabonohu nga njoftimet e projektit",
"refreshTooltip": "Rifresko të dhënat e projektit",
"settingsTooltip": "Hap cilësimet e projektit",
"saveAsTemplateTooltip": "Ruaj këtë projekt si model",
"inviteTooltip": "Fto anëtarë ekipit në këtë projekt",
"saveAsTemplateTooltip": "Ruaj këtë projekt si shabllon",
"inviteTooltip": "Fto anëtarët e ekipit në këtë projekt",
"createTaskTooltip": "Krijo një detyrë të re",
"importTaskTooltip": "Importo detyrë nga modeli",
"navigateBackTooltip": "Kthehu tek lista e projekteve",
"importTaskTooltip": "Importo detyrë nga shablloni",
"navigateBackTooltip": "Kthehu listën e projekteve",
"projectStatusTooltip": "Statusi i projektit",
"projectDatesInfo": "Informacion për kohëzgjatjen e projektit",
"projectCategoryTooltip": "Kategoria e projektit"
"projectDatesInfo": "Informacioni i afateve të projektit",
"projectCategoryTooltip": "Kategoria e projektit",
"defaultTaskName": "Detyrë Pa Emër"
}

View File

@@ -68,9 +68,10 @@
"clearing": "Po pastron...",
"cancel": "Anulo",
"search": "Kërko",
"groupedBy": "I grupuar sipas",
"manageStatuses": "Menaxho statuset",
"managePhases": "Menaxho fazat",
"groupedBy": "Grupuar sipas",
"manage": "Menaxho",
"manageStatuses": "Menaxho Statuset",
"managePhases": "Menaxho Fazat",
"dragToReorderStatuses": "Statuset janë të organizuara sipas kategorive. Tërhiq për të rirenditur brenda kategorive. Kliko 'Shto status' për të krijuar statuse të reja në çdo kategori.",
"enterNewStatusName": "Shkruani emrin e statusit të ri...",
"addStatus": "Shto status",

View File

@@ -1,21 +1,39 @@
{
"noTasksInGroup": "Nuk ka detyra në këtë grup",
"noTasksInGroupDescription": "Shtoni një detyrë për të filluar",
"addFirstTask": "Shtoni detyrën tuaj të parë",
"noTasksInGroupDescription": "Shto një detyrë për të filluar",
"addFirstTask": "Shto detyrën e parë",
"openTask": "Hap",
"subtask": "nën-detyrë",
"subtasks": "nën-detyra",
"subtask": "nëndetyrë",
"subtasks": "nëndetyra",
"comment": "koment",
"comments": "komente",
"attachment": "bashkëngjitje",
"attachments": "bashkëngjitje",
"enterSubtaskName": "Shkruani emrin e nën-detyrës...",
"enterSubtaskName": "Shkruani emrin e nëndetyrës...",
"add": "Shto",
"cancel": "Anulo",
"renameGroup": "Riemërto Grupin",
"renameStatus": "Riemërto Statusin",
"renamePhase": "Riemërto Fazën",
"changeCategory": "Ndrysho Kategorinë",
"clickToEditGroupName": "Kliko për të ndryshuar emrin e grupit",
"enterGroupName": "Shkruani emrin e grupit"
"clickToEditGroupName": "Kliko për të redaktuar emrin e grupit",
"enterGroupName": "Shkruani emrin e grupit",
"todo": "Për t'u Bërë",
"inProgress": "Në Progres",
"done": "E Kryer",
"defaultTaskName": "Detyrë Pa Emër",
"indicators": {
"tooltips": {
"subtasks": "{{count}} nëndetyrë",
"subtasks_plural": "{{count}} nëndetyra",
"comments": "{{count}} koment",
"comments_plural": "{{count}} komente",
"attachments": "{{count}} bashkëngjitje",
"attachments_plural": "{{count}} bashkëngjitje",
"subscribers": "Detyra ka abonues",
"dependencies": "Detyra ka varësi",
"recurring": "Detyrë e përsëritur"
}
}
}

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Phasen konfigurieren",
"phaseLabel": "Phasenbezeichnung",
"enterPhaseName": "Phasennamen eingeben",
"configure": "Konfigurieren",
"phaseLabel": "Phasen-Label",
"enterPhaseName": "Phasenname eingeben",
"addOption": "Option hinzufügen",
"phaseOptions": "Phasenoptionen",
"optionsText": "Optionen",
"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",
"no": "Keine",
"found": "gefunden",
"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",

View File

@@ -4,27 +4,28 @@
"createTask": "Aufgabe erstellen",
"settings": "Einstellungen",
"subscribe": "Abonnieren",
"unsubscribe": "Abonnement beenden",
"unsubscribe": "Abmelden",
"deleteProject": "Projekt löschen",
"startDate": "Startdatum",
"endDate": "Enddatum",
"projectSettings": "Projekteinstellungen",
"projectSummary": "Projektzusammenfassung",
"receiveProjectSummary": "Erhalten Sie jeden Abend eine Projektzusammenfassung.",
"receiveProjectSummary": "Jeden Abend eine Projektzusammenfassung erhalten.",
"refreshProject": "Projekt aktualisieren",
"saveAsTemplate": "Als Vorlage speichern",
"invite": "Einladen",
"share": "Teilen",
"subscribeTooltip": "Projektbenachrichtigungen abonnieren",
"unsubscribeTooltip": "Projektbenachrichtigungen beenden",
"unsubscribeTooltip": "Projektbenachrichtigungen abmelden",
"refreshTooltip": "Projektdaten aktualisieren",
"settingsTooltip": "Projekteinstellungen öffnen",
"saveAsTemplateTooltip": "Dieses Projekt als Vorlage speichern",
"inviteTooltip": "Teammitglieder zu diesem Projekt einladen",
"createTaskTooltip": "Neue Aufgabe erstellen",
"createTaskTooltip": "Eine neue Aufgabe erstellen",
"importTaskTooltip": "Aufgabe aus Vorlage importieren",
"navigateBackTooltip": "Zurück zur Projektliste",
"projectStatusTooltip": "Projektstatus",
"projectDatesInfo": "Informationen zum Projektzeitraum",
"projectCategoryTooltip": "Projektkategorie"
"projectDatesInfo": "Projekt-Zeitleisten-Informationen",
"projectCategoryTooltip": "Projektkategorie",
"defaultTaskName": "Unbenannte Aufgabe"
}

View File

@@ -69,6 +69,7 @@
"cancel": "Abbrechen",
"search": "Suchen",
"groupedBy": "Gruppiert nach",
"manage": "Verwalten",
"manageStatuses": "Status verwalten",
"managePhases": "Phasen verwalten",
"dragToReorderStatuses": "Status sind nach Kategorien organisiert. Ziehen Sie, um innerhalb von Kategorien neu zu ordnen. Klicken Sie auf 'Status hinzufügen', um neue Status in jeder Kategorie zu erstellen.",

View File

@@ -9,7 +9,7 @@
"comments": "Kommentare",
"attachment": "Anhang",
"attachments": "Anhänge",
"enterSubtaskName": "Unteraufgabenname eingeben...",
"enterSubtaskName": "Geben Sie den Namen der Unteraufgabe ein...",
"add": "Hinzufügen",
"cancel": "Abbrechen",
"renameGroup": "Gruppe umbenennen",
@@ -17,5 +17,23 @@
"renamePhase": "Phase umbenennen",
"changeCategory": "Kategorie ändern",
"clickToEditGroupName": "Klicken Sie, um den Gruppennamen zu bearbeiten",
"enterGroupName": "Gruppennamen eingeben"
"enterGroupName": "Geben Sie den Gruppennamen ein",
"todo": "Zu erledigen",
"inProgress": "In Bearbeitung",
"done": "Erledigt",
"defaultTaskName": "Unbenannte Aufgabe",
"indicators": {
"tooltips": {
"subtasks": "{{count}} Unteraufgabe",
"subtasks_plural": "{{count}} Unteraufgaben",
"comments": "{{count}} Kommentar",
"comments_plural": "{{count}} Kommentare",
"attachments": "{{count}} Anhang",
"attachments_plural": "{{count}} Anhänge",
"subscribers": "Aufgabe hat Abonnenten",
"dependencies": "Aufgabe hat Abhängigkeiten",
"recurring": "Wiederkehrende Aufgabe"
}
}
}

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Configure Phases",
"configure": "Configure",
"phaseLabel": "Phase Label",
"enterPhaseName": "Enter phase name",
"addOption": "Add Option",
"phaseOptions": "Phase Options",
"optionsText": "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",
"no": "No",
"found": "found",
"deletePhase": "Delete Phase",
"deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.",
"rename": "Rename",

View File

@@ -26,5 +26,6 @@
"navigateBackTooltip": "Go back to projects list",
"projectStatusTooltip": "Project status",
"projectDatesInfo": "Project timeline information",
"projectCategoryTooltip": "Project category"
"projectCategoryTooltip": "Project category",
"defaultTaskName": "Untitled Task"
}

View File

@@ -69,6 +69,7 @@
"cancel": "Cancel",
"search": "Search",
"groupedBy": "Grouped by",
"manage": "Manage",
"manageStatuses": "Manage Statuses",
"managePhases": "Manage Phases",
"dragToReorderStatuses": "Statuses are organized by categories. Drag to reorder within categories. Click 'Add Status' to create new statuses in each category.",

View File

@@ -18,6 +18,10 @@
"changeCategory": "Change Category",
"clickToEditGroupName": "Click to edit group name",
"enterGroupName": "Enter group name",
"todo": "To Do",
"inProgress": "Doing",
"done": "Done",
"defaultTaskName": "Untitled Task",
"indicators": {
"tooltips": {

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Configurar fases",
"phaseLabel": "Etiqueta de fase",
"enterPhaseName": "Introducir nombre de la fase",
"addOption": "Agregar opción",
"phaseOptions": "Opciones de fase",
"configurePhases": "Configurar Fases",
"configure": "Configurar",
"phaseLabel": "Etiqueta de Fase",
"enterPhaseName": "Ingresa el nombre de la fase",
"addOption": "Agregar Opción",
"phaseOptions": "Opciones de Fase",
"optionsText": "Opciones",
"dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.",
"enterNewPhaseName": "Introducir nuevo nombre de fase...",
"addPhase": "Añadir Fase",
"enterNewPhaseName": "Ingresa el nombre de la nueva fase...",
"addPhase": "Agregar Fase",
"noPhasesFound": "No se encontraron fases",
"no": "No",
"found": "encontrado",
"deletePhase": "Eliminar Fase",
"deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.",
"rename": "Renombrar",

View File

@@ -4,19 +4,19 @@
"createTask": "Crear tarea",
"settings": "Configuración",
"subscribe": "Suscribirse",
"unsubscribe": "Cancelar suscripción",
"unsubscribe": "Desuscribirse",
"deleteProject": "Eliminar proyecto",
"startDate": "Fecha de inicio",
"endDate": "Fecha de finalización",
"endDate": "Fecha de fin",
"projectSettings": "Configuración del proyecto",
"projectSummary": "Resumen del proyecto",
"receiveProjectSummary": "Recibe un resumen del proyecto cada noche.",
"receiveProjectSummary": "Recibir un resumen del proyecto cada noche.",
"refreshProject": "Actualizar proyecto",
"saveAsTemplate": "Guardar como plantilla",
"invite": "Invitar",
"share": "Compartir",
"subscribeTooltip": "Suscribirse a notificaciones del proyecto",
"unsubscribeTooltip": "Cancelar suscripción a notificaciones del proyecto",
"subscribeTooltip": "Suscribirse a las notificaciones del proyecto",
"unsubscribeTooltip": "Desuscribirse de las notificaciones del proyecto",
"refreshTooltip": "Actualizar datos del proyecto",
"settingsTooltip": "Abrir configuración del proyecto",
"saveAsTemplateTooltip": "Guardar este proyecto como plantilla",
@@ -25,6 +25,7 @@
"importTaskTooltip": "Importar tarea desde plantilla",
"navigateBackTooltip": "Volver a la lista de proyectos",
"projectStatusTooltip": "Estado del proyecto",
"projectDatesInfo": "Información de cronograma del proyecto",
"projectCategoryTooltip": "Categoría del proyecto"
"projectDatesInfo": "Información de la cronología del proyecto",
"projectCategoryTooltip": "Categoría del proyecto",
"defaultTaskName": "Tarea Sin Título"
}

View File

@@ -69,8 +69,9 @@
"cancel": "Cancelar",
"search": "Buscar",
"groupedBy": "Agrupado por",
"manageStatuses": "Gestionar estados",
"managePhases": "Gestionar fases",
"manage": "Gestionar",
"manageStatuses": "Gestionar Estados",
"managePhases": "Gestionar Fases",
"dragToReorderStatuses": "Los estados están organizados por categorías. Arrastra para reordenar dentro de las categorías. Haz clic en 'Agregar estado' para crear nuevos estados en cada categoría.",
"enterNewStatusName": "Ingrese el nombre del nuevo estado...",
"addStatus": "Agregar estado",

View File

@@ -17,5 +17,23 @@
"renamePhase": "Renombrar Fase",
"changeCategory": "Cambiar Categoría",
"clickToEditGroupName": "Haz clic para editar el nombre del grupo",
"enterGroupName": "Ingresa el nombre del grupo"
"enterGroupName": "Ingresa el nombre del grupo",
"todo": "Por Hacer",
"inProgress": "En Progreso",
"done": "Hecho",
"defaultTaskName": "Tarea Sin Título",
"indicators": {
"tooltips": {
"subtasks": "{{count}} subtarea",
"subtasks_plural": "{{count}} subtareas",
"comments": "{{count}} comentario",
"comments_plural": "{{count}} comentarios",
"attachments": "{{count}} adjunto",
"attachments_plural": "{{count}} adjuntos",
"subscribers": "La tarea tiene suscriptores",
"dependencies": "La tarea tiene dependencias",
"recurring": "Tarea recurrente"
}
}
}

View File

@@ -1,13 +1,17 @@
{
"configurePhases": "Configurar fases",
"phaseLabel": "Etiqueta de fase",
"configurePhases": "Configurar Fases",
"configure": "Configurar",
"phaseLabel": "Rótulo da Fase",
"enterPhaseName": "Digite o nome da fase",
"addOption": "Adicionar Opção",
"phaseOptions": "Opções de Fase",
"optionsText": "Opções",
"dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.",
"enterNewPhaseName": "Digite o novo nome da fase...",
"enterNewPhaseName": "Digite o nome da nova fase...",
"addPhase": "Adicionar Fase",
"noPhasesFound": "Nenhuma fase encontrada",
"no": "Nenhuma",
"found": "encontrada",
"deletePhase": "Excluir Fase",
"deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.",
"rename": "Renomear",

View File

@@ -7,10 +7,10 @@
"unsubscribe": "Cancelar inscrição",
"deleteProject": "Excluir projeto",
"startDate": "Data de início",
"endDate": "Data de término",
"endDate": "Data de fim",
"projectSettings": "Configurações do projeto",
"projectSummary": "Resumo do projeto",
"receiveProjectSummary": "Receba um resumo do projeto todas as noites.",
"receiveProjectSummary": "Receber um resumo do projeto todas as noites.",
"refreshProject": "Atualizar projeto",
"saveAsTemplate": "Salvar como modelo",
"invite": "Convidar",
@@ -22,9 +22,10 @@
"saveAsTemplateTooltip": "Salvar este projeto como modelo",
"inviteTooltip": "Convidar membros da equipe para este projeto",
"createTaskTooltip": "Criar uma nova tarefa",
"importTaskTooltip": "Importar tarefa de modelo",
"navigateBackTooltip": "Voltar para lista de projetos",
"importTaskTooltip": "Importar tarefa do modelo",
"navigateBackTooltip": "Voltar para a lista de projetos",
"projectStatusTooltip": "Status do projeto",
"projectDatesInfo": "Informações do cronograma do projeto",
"projectCategoryTooltip": "Categoria do projeto"
"projectDatesInfo": "Informações da linha do tempo do projeto",
"projectCategoryTooltip": "Categoria do projeto",
"defaultTaskName": "Tarefa Sem Título"
}

View File

@@ -69,8 +69,9 @@
"cancel": "Cancelar",
"search": "Pesquisar",
"groupedBy": "Agrupado por",
"manageStatuses": "Gerenciar status",
"managePhases": "Gerenciar fases",
"manage": "Gerenciar",
"manageStatuses": "Gerenciar Status",
"managePhases": "Gerenciar Fases",
"dragToReorderStatuses": "Os status estão organizados por categorias. Arraste para reordenar dentro das categorias. Clique em 'Adicionar status' para criar novos status em cada categoria.",
"enterNewStatusName": "Digite o nome do novo status...",
"addStatus": "Adicionar status",

View File

@@ -17,5 +17,23 @@
"renamePhase": "Renomear Fase",
"changeCategory": "Alterar Categoria",
"clickToEditGroupName": "Clique para editar o nome do grupo",
"enterGroupName": "Digite o nome do grupo"
"enterGroupName": "Digite o nome do grupo",
"todo": "A Fazer",
"inProgress": "Em Andamento",
"done": "Concluído",
"defaultTaskName": "Tarefa Sem Título",
"indicators": {
"tooltips": {
"subtasks": "{{count}} subtarefa",
"subtasks_plural": "{{count}} subtarefas",
"comments": "{{count}} comentário",
"comments_plural": "{{count}} comentários",
"attachments": "{{count}} anexo",
"attachments_plural": "{{count}} anexos",
"subscribers": "Tarefa tem assinantes",
"dependencies": "Tarefa tem dependências",
"recurring": "Tarefa recorrente"
}
}
}

View File

@@ -1,20 +1,24 @@
{
"configurePhases": "配置阶段",
"phaseLabel": "阶段标签",
"enterPhaseName": "输入阶段名称",
"addOption": "添加选项",
"phaseOptions": "阶段选项",
"dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。",
"enterNewPhaseName": "输入新阶段名称...",
"addPhase": "添加阶段",
"noPhasesFound": "未找到阶段",
"deletePhase": "删除阶段",
"deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。",
"rename": "重命名",
"delete": "删除",
"create": "创建",
"cancel": "取消",
"selectColor": "选择颜色",
"managePhases": "管理阶段",
"close": "关闭"
"configurePhases": "配置阶段",
"configure": "配置",
"phaseLabel": "阶段标签",
"enterPhaseName": "输入阶段名称",
"addOption": "添加选项",
"phaseOptions": "阶段选项",
"optionsText": "选项",
"dragToReorderPhases": "拖拽阶段来重新排序。每个阶段可以有不同的颜色。",
"enterNewPhaseName": "输入新阶段名称...",
"addPhase": "添加阶段",
"noPhasesFound": "未找到阶段",
"no": "没有",
"found": "找到",
"deletePhase": "删除阶段",
"deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤消。",
"rename": "重命名",
"delete": "删除",
"create": "创建",
"cancel": "取消",
"selectColor": "选择颜色",
"managePhases": "管理阶段",
"close": "关闭"
}

View File

@@ -10,7 +10,7 @@
"endDate": "结束日期",
"projectSettings": "项目设置",
"projectSummary": "项目摘要",
"receiveProjectSummary": "每接收项目摘要。",
"receiveProjectSummary": "每天晚上接收项目摘要。",
"refreshProject": "刷新项目",
"saveAsTemplate": "保存为模板",
"invite": "邀请",
@@ -25,6 +25,7 @@
"importTaskTooltip": "从模板导入任务",
"navigateBackTooltip": "返回项目列表",
"projectStatusTooltip": "项目状态",
"projectDatesInfo": "项目时间安排信息",
"projectCategoryTooltip": "项目类别"
"projectDatesInfo": "项目时间线信息",
"projectCategoryTooltip": "项目类别",
"defaultTaskName": "无标题任务"
}

View File

@@ -62,7 +62,8 @@
"clearing": "清除中...",
"cancel": "取消",
"search": "搜索",
"groupedBy": "分组依据",
"groupedBy": "分组方式",
"manage": "管理",
"manageStatuses": "管理状态",
"managePhases": "管理阶段",
"dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。",

View File

@@ -18,6 +18,10 @@
"changeCategory": "更改类别",
"clickToEditGroupName": "点击编辑组名称",
"enterGroupName": "输入组名称",
"todo": "待办",
"inProgress": "进行中",
"done": "已完成",
"defaultTaskName": "无标题任务",
"indicators": {
"tooltips": {

View File

@@ -1,12 +1,12 @@
import apiClient from '@/api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { toQueryString } from '@/utils/toQueryString';
const rootUrl = `${API_BASE_URL}/task-phases`;
interface UpdateSortOrderBody {
export interface UpdateSortOrderBody {
from_index: number;
to_index: number;
phases: ITaskPhase[];
@@ -14,9 +14,10 @@ interface UpdateSortOrderBody {
}
export const phasesApiService = {
addPhaseOption: async (projectId: string) => {
addPhaseOption: async (projectId: string, name?: string) => {
const q = toQueryString({ id: projectId, current_project_id: projectId });
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`);
const body = name ? { name } : {};
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`, body);
return response.data;
},

View File

@@ -139,7 +139,7 @@ const EnhancedKanbanCreateSection: React.FC = () => {
}
if (groupBy === IGroupBy.PHASE) {
try {
const response = await phasesApiService.addPhaseOption(projectId);
const response = await phasesApiService.addPhaseOption(projectId, name);
if (response.done && response.body) {
dispatch(fetchEnhancedKanbanGroups(projectId));
}

View File

@@ -73,8 +73,17 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
if (tasks.length === 0) {
return (
<div className="virtualized-empty-state" style={{ height }}>
<div className="empty-message">No tasks in this group</div>
<div className="virtualized-empty-state" style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="empty-message" style={{
padding: '32px 24px',
color: '#8c8c8c',
fontSize: '14px',
backgroundColor: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
No tasks in this group
</div>
</div>
);
}

View File

@@ -173,7 +173,7 @@ const KanbanGroup: React.FC<TaskGroupProps> = ({
.kanban-group-empty {
text-align: center;
color: #bfbfbf;
padding: 32px 0;
padding: 48px 16px;
}
.kanban-group-add-task {
padding: 12px;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface GroupProgressBarProps {
todoProgress: number;
doingProgress: number;
doneProgress: number;
groupType: string;
}
const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
todoProgress,
doingProgress,
doneProgress,
groupType
}) => {
const { t } = useTranslation('task-management');
// Only show for priority and phase grouping
if (groupType !== 'priority' && groupType !== 'phase') {
return null;
}
const total = todoProgress + doingProgress + doneProgress;
// Don't show if no progress values exist
if (total === 0) {
return null;
}
return (
<div className="flex items-center gap-2">
{/* Compact progress text */}
<span className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap font-medium">
{doneProgress}% {t('done')}
</span>
{/* Compact progress bar */}
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div className="h-full flex">
{/* Todo section - light green */}
{todoProgress > 0 && (
<div
className="bg-green-200 dark:bg-green-800 transition-all duration-300"
style={{ width: `${(todoProgress / total) * 100}%` }}
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{/* Doing section - medium green */}
{doingProgress > 0 && (
<div
className="bg-green-400 dark:bg-green-600 transition-all duration-300"
style={{ width: `${(doingProgress / total) * 100}%` }}
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{/* Done section - dark green */}
{doneProgress > 0 && (
<div
className="bg-green-600 dark:bg-green-400 transition-all duration-300"
style={{ width: `${(doneProgress / total) * 100}%` }}
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
{/* Small legend dots with better spacing */}
<div className="flex items-center gap-1">
{todoProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{doingProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{doneProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
);
};
export default GroupProgressBar;

View File

@@ -3,12 +3,13 @@ import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
import GroupProgressBar from './GroupProgressBar';
import { useTranslation } from 'react-i18next';
import { getContrastColor } from '@/utils/colorUtils';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { selectGroups, fetchTasksV3, selectAllTasksArray } from '@/features/task-management/task-management.slice';
import { selectCurrentGrouping } from '@/features/task-management/grouping.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
@@ -27,6 +28,10 @@ interface TaskGroupHeaderProps {
name: string;
count: number;
color?: string; // Color for the group indicator
todo_progress?: number;
doing_progress?: number;
done_progress?: number;
groupType?: string;
};
isCollapsed: boolean;
onToggle: () => void;
@@ -38,13 +43,14 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
const dispatch = useAppDispatch();
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
const groups = useAppSelector(selectGroups);
const allTasks = useAppSelector(selectAllTasksArray);
const currentGrouping = useAppSelector(selectCurrentGrouping);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer);
const { trackMixpanelEvent } = useMixpanelTracking();
const { isOwnerOrAdmin } = useAuthService();
const [dropdownVisible, setDropdownVisible] = useState(false);
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isChangingCategory, setIsChangingCategory] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
@@ -62,6 +68,74 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
return currentGroup?.taskIds || [];
}, [currentGroup]);
// Calculate group progress values dynamically
const groupProgressValues = useMemo(() => {
if (!currentGroup || !allTasks.length) {
return { todoProgress: 0, doingProgress: 0, doneProgress: 0 };
}
const tasksInCurrentGroup = currentGroup.taskIds
.map(taskId => allTasks.find(task => task.id === taskId))
.filter(task => task !== undefined);
if (tasksInCurrentGroup.length === 0) {
return { todoProgress: 0, doingProgress: 0, doneProgress: 0 };
}
// If we're grouping by status, show progress based on task completion
if (currentGrouping === 'status') {
// For status grouping, calculate based on task progress values
const progressStats = tasksInCurrentGroup.reduce((acc, task) => {
const progress = task.progress || 0;
if (progress === 0) {
acc.todo += 1;
} else if (progress === 100) {
acc.done += 1;
} else {
acc.doing += 1;
}
return acc;
}, { todo: 0, doing: 0, done: 0 });
const totalTasks = tasksInCurrentGroup.length;
return {
todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) : 0,
doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) : 0,
doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) : 0,
};
} else {
// For priority/phase grouping, show progress based on status distribution
// Use a simplified approach based on status names and common patterns
const statusCounts = tasksInCurrentGroup.reduce((acc, task) => {
// Find the status by ID first
const statusInfo = statusList.find(s => s.id === task.status);
const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || '';
// Categorize based on common status name patterns
if (statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog')) {
acc.todo += 1;
} else if (statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development')) {
acc.doing += 1;
} else if (statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved')) {
acc.done += 1;
} else {
// Default unknown statuses to "doing" (in progress)
acc.doing += 1;
}
return acc;
}, { todo: 0, doing: 0, done: 0 });
const totalTasks = tasksInCurrentGroup.length;
return {
todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) : 0,
doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) : 0,
doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) : 0,
};
}
}, [currentGroup, allTasks, statusList, currentGrouping]);
// Calculate selection state for this group
const { isAllSelected, isPartiallySelected } = useMemo(() => {
if (tasksInGroup.length === 0) {
@@ -94,7 +168,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Handle inline name editing
const handleNameSave = useCallback(async () => {
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return;
// If no changes or already renaming, just exit editing mode
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) {
setIsEditingName(false);
setEditingName(group.name);
return;
}
setIsRenaming(true);
try {
@@ -122,12 +201,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh task list to get updated group names
dispatch(fetchTasksV3(projectId));
setIsEditingName(false);
} catch (error) {
logger.error('Error renaming group:', error);
setEditingName(group.name);
} finally {
setIsEditingName(false);
setIsRenaming(false);
}
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
@@ -150,9 +229,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
}, [group.name, handleNameSave]);
const handleNameBlur = useCallback(() => {
setIsEditingName(false);
setEditingName(group.name);
}, [group.name]);
handleNameSave();
}, [handleNameSave]);
// Handle dropdown menu actions
const handleRenameGroup = useCallback(() => {
@@ -161,10 +239,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
setEditingName(group.name);
}, [group.name]);
const handleChangeCategory = useCallback(() => {
setDropdownVisible(false);
setCategoryModalVisible(true);
}, []);
// Handle category change
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
@@ -182,7 +257,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh status list and tasks
dispatch(fetchStatuses(projectId));
dispatch(fetchTasksV3(projectId));
setCategoryModalVisible(false);
} catch (error) {
logger.error('Error changing category:', error);
@@ -209,19 +283,30 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Only show "Change Category" when grouped by status
if (currentGrouping === 'status') {
items.push({
key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'),
const categorySubMenuItems = statusCategories.map((category) => ({
key: `category-${category.id}`,
label: (
<div className="flex items-center gap-2">
<Badge color={category.color_code} />
<span>{category.name}</span>
</div>
),
onClick: (e: any) => {
e?.domEvent?.stopPropagation();
handleChangeCategory();
handleCategoryChange(category.id || '', e?.domEvent);
},
});
}));
items.push({
key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'),
children: categorySubMenuItems,
} as any);
}
return items;
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]);
}, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
@@ -232,75 +317,146 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
},
});
return (
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
height: '36px',
minHeight: '36px',
maxHeight: '36px'
}}
onClick={onToggle}
>
{/* Drag Handle Space - ultra minimal width */}
<div style={{ width: '20px' }} className="flex items-center justify-center">
{/* Chevron button */}
<button
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
style={{ backgroundColor: 'transparent', color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transformOrigin: 'center'
return (
<div className="relative flex items-center">
<div
ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
height: '36px',
minHeight: '36px',
maxHeight: '36px'
}}
onClick={onToggle}
>
{/* Drag Handle Space - ultra minimal width */}
<div style={{ width: '20px' }} className="flex items-center justify-center">
{/* Chevron button */}
<button
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
style={{ backgroundColor: 'transparent', color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
</div>
</button>
</div>
<div
className="transition-transform duration-300 ease-out"
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transformOrigin: 'center'
}}
>
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
</div>
</button>
</div>
{/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onChange={handleSelectAllChange}
onClick={(e) => e.stopPropagation()}
style={{
color: headerTextColor,
}}
/>
</div>
{/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onChange={handleSelectAllChange}
onClick={(e) => e.stopPropagation()}
style={{
color: headerTextColor,
}}
/>
</div>
{/* Group indicator and name - no gap at all */}
{/* Group indicator and name - no gap at all */}
<div className="flex items-center flex-1 ml-1">
{/* Group name and count */}
<div className="flex items-center">
<span
className="text-sm font-semibold pr-2"
style={{ color: headerTextColor }}
>
{group.name}
</span>
{isEditingName ? (
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleNameKeyDown}
onBlur={handleNameBlur}
autoFocus
size="small"
className="text-sm font-semibold"
style={{
width: 'auto',
minWidth: '100px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
color: headerTextColor,
border: '1px solid rgba(255, 255, 255, 0.3)'
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
style={{ color: headerTextColor }}
onClick={handleNameClick}
>
{group.name}
</span>
)}
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count})
</span>
</div>
</div>
{/* Three-dot menu - only show for status and phase grouping */}
{menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
<div className="flex items-center ml-2">
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
open={dropdownVisible}
onOpenChange={setDropdownVisible}
placement="bottomRight"
overlayStyle={{ zIndex: 1000 }}
>
<button
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-colors duration-200"
style={{ color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
setDropdownVisible(!dropdownVisible);
}}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</Dropdown>
</div>
)}
</div>
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
(groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && (
<div
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
style={{
position: 'sticky',
right: '16px',
zIndex: 35, // Higher than header
minWidth: '160px',
height: '30px'
}}
>
<GroupProgressBar
todoProgress={groupProgressValues.todoProgress}
doingProgress={groupProgressValues.doingProgress}
doneProgress={groupProgressValues.doneProgress}
groupType={group.groupType || currentGrouping || ''}
/>
</div>
)}
</div>
);
};

View File

@@ -54,6 +54,7 @@ import {
setCustomColumnModalAttributes,
toggleCustomColumnModalOpen,
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
// Components
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
@@ -64,6 +65,7 @@ import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-t
import AddTaskRow from './components/AddTaskRow';
import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents';
import TaskListSkeleton from './components/TaskListSkeleton';
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
// Hooks and utilities
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
@@ -212,6 +214,7 @@ const TaskListV2Section: React.FC = () => {
if (urlProjectId) {
dispatch(fetchTasksV3(urlProjectId));
dispatch(fetchTaskListColumns(urlProjectId));
dispatch(fetchPhasesByProjectId(urlProjectId));
}
}, [dispatch, urlProjectId]);
@@ -452,6 +455,10 @@ const TaskListV2Section: React.FC = () => {
name: group.title,
count: group.actualCount,
color: group.color,
todo_progress: group.todo_progress,
doing_progress: group.doing_progress,
done_progress: group.done_progress,
groupType: group.groupType,
}}
isCollapsed={isGroupCollapsed}
onToggle={() => handleGroupCollapse(group.id)}
@@ -459,7 +466,7 @@ const TaskListV2Section: React.FC = () => {
/>
{isGroupEmpty && !isGroupCollapsed && (
<div className="relative w-full">
<div className="flex items-center min-w-max px-1 py-3">
<div className="flex items-center min-w-max px-1 py-6">
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
@@ -478,7 +485,7 @@ const TaskListV2Section: React.FC = () => {
})}
</div>
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-3 py-1.5 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-4 py-3 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
{t('noTasksInGroup')}
</div>
</div>
@@ -760,6 +767,9 @@ const TaskListV2Section: React.FC = () => {
{/* Custom Column Modal */}
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
{/* Convert To Subtask Drawer */}
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
</div>
</DndContext>
);

View File

@@ -0,0 +1,529 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import { Task } from '@/types/task-management.types';
import { tasksApiService } from '@/api/tasks/tasks.api.service';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
import {
deleteTask,
fetchTasksV3,
IGroupBy,
toggleTaskExpansion,
updateTaskAssignees,
} from '@/features/task-management/task-management.slice';
import { deselectAll, selectTasks } from '@/features/projects/bulkActions/bulkActionSlice';
import { setConvertToSubtaskDrawerOpen } from '@/features/tasks/tasks.slice';
import { useTranslation } from 'react-i18next';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
evt_project_task_list_context_menu_archive,
evt_project_task_list_context_menu_assign_me,
evt_project_task_list_context_menu_delete,
} from '@/shared/worklenz-analytics-events';
import {
DeleteOutlined,
DoubleRightOutlined,
InboxOutlined,
RetweetOutlined,
UserAddOutlined,
LoadingOutlined,
} from '@ant-design/icons';
interface TaskContextMenuProps {
task: Task;
projectId: string;
position: { x: number; y: number };
onClose: () => void;
}
const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
task,
projectId,
position,
onClose,
}) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('task-list-table');
const { socket, connected } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
const { groups: taskGroups } = useAppSelector(state => state.taskManagement);
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
const currentGrouping = useAppSelector(state => state.grouping.currentGrouping);
const archived = useAppSelector(state => state.taskReducer.archived);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleAssignToMe = useCallback(async () => {
if (!projectId || !task.id || !currentSession?.team_member_id) return;
try {
setUpdatingAssignToMe(true);
// Immediate UI update - add current user to assignees
const currentUser = {
id: currentSession.team_member_id,
name: currentSession.name || '',
email: currentSession.email || '',
avatar_url: currentSession.avatar_url || '',
team_member_id: currentSession.team_member_id,
};
const updatedAssignees = task.assignees || [];
const updatedAssigneeNames = task.assignee_names || [];
// Check if current user is already assigned
const isAlreadyAssigned = updatedAssignees.includes(currentSession.team_member_id);
if (!isAlreadyAssigned) {
// Add current user to assignees for immediate UI feedback
const newAssignees = [...updatedAssignees, currentSession.team_member_id];
const newAssigneeNames = [...updatedAssigneeNames, currentUser];
// Update Redux store immediately for instant UI feedback
dispatch(
updateTaskAssignees({
taskId: task.id,
assigneeIds: newAssignees,
assigneeNames: newAssigneeNames,
})
);
}
const body: IBulkAssignRequest = {
tasks: [task.id],
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
// Socket event will handle syncing with other users
}
} catch (error) {
logger.error('Error assigning to me:', error);
// Revert the optimistic update on error
dispatch(
updateTaskAssignees({
taskId: task.id,
assigneeIds: task.assignees || [],
assigneeNames: task.assignee_names || [],
})
);
} finally {
setUpdatingAssignToMe(false);
onClose();
}
}, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]);
const handleArchive = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const res = await taskListBulkActionsApiService.archiveTasks(
{
tasks: [task.id],
project_id: projectId,
},
false
);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
dispatch(deleteTask(task.id));
dispatch(deselectAll());
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
}
}
} catch (error) {
logger.error('Error archiving task:', error);
} finally {
onClose();
}
}, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]);
const handleDelete = useCallback(async () => {
if (!projectId || !task.id) return;
try {
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteTask(task.id));
dispatch(deselectAll());
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
}
}
} catch (error) {
logger.error('Error deleting task:', error);
} finally {
onClose();
}
}, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]);
const handleStatusMoveTo = useCallback(
async (targetId: string) => {
if (!projectId || !task.id || !targetId) return;
try {
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
status_id: targetId,
parent_task: task.parent_task_id || null,
team_id: currentSession?.team_id,
})
);
} catch (error) {
logger.error('Error moving status:', error);
} finally {
onClose();
}
},
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
);
const handlePriorityMoveTo = useCallback(
async (targetId: string) => {
if (!projectId || !task.id || !targetId) return;
try {
socket?.emit(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
priority_id: targetId,
parent_task: task.parent_task_id || null,
team_id: currentSession?.team_id,
})
);
} catch (error) {
logger.error('Error moving priority:', error);
} finally {
onClose();
}
},
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
);
const handlePhaseMoveTo = useCallback(
async (targetId: string) => {
if (!projectId || !task.id || !targetId) return;
try {
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: task.id,
phase_id: targetId,
parent_task: task.parent_task_id || null,
team_id: currentSession?.team_id,
});
} catch (error) {
logger.error('Error moving phase:', error);
} finally {
onClose();
}
},
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
);
const getMoveToOptions = useCallback(() => {
let options: { key: string; label: React.ReactNode; onClick: () => void }[] = [];
if (currentGrouping === IGroupBy.STATUS) {
options = statusList.filter(status => status.id).map(status => ({
key: status.id!,
label: (
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: status.color_code }}
></span>
<span>{status.name}</span>
</div>
),
onClick: () => handleStatusMoveTo(status.id!),
}));
} else if (currentGrouping === IGroupBy.PRIORITY) {
options = priorityList.filter(priority => priority.id).map(priority => ({
key: priority.id!,
label: (
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: priority.color_code }}
></span>
<span>{priority.name}</span>
</div>
),
onClick: () => handlePriorityMoveTo(priority.id!),
}));
} else if (currentGrouping === IGroupBy.PHASE) {
options = phaseList.filter(phase => phase.id).map(phase => ({
key: phase.id!,
label: (
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: phase.color_code }}
></span>
<span>{phase.name}</span>
</div>
),
onClick: () => handlePhaseMoveTo(phase.id!),
}));
}
return options;
}, [
currentGrouping,
statusList,
priorityList,
phaseList,
handleStatusMoveTo,
handlePriorityMoveTo,
handlePhaseMoveTo,
]);
const handleConvertToTask = useCallback(async () => {
if (!task?.id || !projectId) return;
try {
const res = await tasksApiService.convertToTask(task.id as string, projectId as string);
if (res.done) {
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error converting to task', error);
} finally {
onClose();
}
}, [task?.id, projectId, dispatch, onClose]);
const menuItems = useMemo(() => {
const items = [
{
key: 'assignToMe',
label: (
<button
onClick={handleAssignToMe}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
disabled={updatingAssignToMe}
>
{updatingAssignToMe ? (
<LoadingOutlined className="text-gray-500 dark:text-gray-400" />
) : (
<UserAddOutlined className="text-gray-500 dark:text-gray-400" />
)}
<span>{t('contextMenu.assignToMe')}</span>
</button>
),
},
];
// Add Move To submenu if there are options
const moveToOptions = getMoveToOptions();
if (moveToOptions.length > 0) {
items.push({
key: 'moveTo',
label: (
<div className="relative group">
<button className="flex items-center justify-between gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left">
<div className="flex items-center gap-2">
<RetweetOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.moveTo')}</span>
</div>
<svg
className="w-4 h-4 text-gray-500 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</button>
<ul className="absolute left-full top-0 mt-0 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20 hidden group-hover:block">
{moveToOptions.map(option => (
<li key={option.key}>
<button
onClick={option.onClick}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
{option.label}
</button>
</li>
))}
</ul>
</div>
),
});
}
// Add Archive/Unarchive for parent tasks only
if (!task?.parent_task_id) {
items.push({
key: 'archive',
label: (
<button
onClick={handleArchive}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<InboxOutlined className="text-gray-500 dark:text-gray-400" />
<span>{archived ? t('contextMenu.unarchive') : t('contextMenu.archive')}</span>
</button>
),
});
}
// Add Convert to Sub Task for parent tasks with no subtasks
if (task?.sub_tasks_count === 0 && !task?.parent_task_id) {
items.push({
key: 'convertToSubTask',
label: (
<button
onClick={() => {
// Convert task to the format expected by bulkActionSlice
const projectTask = {
id: task.id,
name: task.title || task.name || '',
task_key: task.task_key,
status: task.status,
status_id: task.status,
priority: task.priority,
phase_id: task.phase,
phase_name: task.phase,
description: task.description,
start_date: task.startDate,
end_date: task.dueDate,
total_hours: task.timeTracking?.estimated || 0,
total_minutes: task.timeTracking?.logged || 0,
progress: task.progress,
sub_tasks_count: task.sub_tasks_count || 0,
assignees: task.assignees?.map((assigneeId: string) => ({
id: assigneeId,
name: '',
email: '',
avatar_url: '',
team_member_id: assigneeId,
project_member_id: assigneeId,
})) || [],
labels: task.labels || [],
manual_progress: false,
created_at: task.createdAt,
updated_at: task.updatedAt,
sort_order: task.order,
};
// Select the task in bulk action reducer
dispatch(selectTasks([projectTask]));
// Open the drawer
dispatch(setConvertToSubtaskDrawerOpen(true));
}}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.convertToSubTask')}</span>
</button>
),
});
}
// Add Convert to Task for subtasks
if (task?.parent_task_id) {
items.push({
key: 'convertToTask',
label: (
<button
onClick={handleConvertToTask}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
>
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
<span>{t('contextMenu.convertToTask')}</span>
</button>
),
});
}
// Add Delete
items.push({
key: 'delete',
label: (
<button
onClick={handleDelete}
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/20 w-full text-left"
>
<DeleteOutlined className="text-red-500 dark:text-red-400" />
<span>{t('contextMenu.delete')}</span>
</button>
),
});
return items;
}, [
task,
projectId,
updatingAssignToMe,
archived,
handleAssignToMe,
handleArchive,
handleDelete,
handleConvertToTask,
getMoveToOptions,
dispatch,
t,
]);
return (
<div
ref={menuRef}
className="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 min-w-48"
style={{
top: position.y,
left: position.x,
zIndex: 9999,
}}
>
<ul className="list-none p-0 m-0">
{menuItems.map(item => (
<li key={item.key} className="relative group">
{item.label}
</li>
))}
</ul>
</div>
);
};
export default TaskContextMenu;

View File

@@ -2,6 +2,7 @@ import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import { createPortal } from 'react-dom';
import { Task } from '@/types/task-management.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
@@ -10,6 +11,7 @@ import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useTranslation } from 'react-i18next';
import { getTaskDisplayName } from './TaskRowColumns';
import TaskContextMenu from './TaskContextMenu';
interface TitleColumnProps {
width: string;
@@ -41,6 +43,10 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
const { t } = useTranslation('task-list-table');
const inputRef = useRef<InputRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Context menu state
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
// Handle task expansion toggle
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
@@ -71,6 +77,24 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
onEditTaskName(false);
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]);
// Handle context menu
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Use clientX and clientY directly for fixed positioning
setContextMenuPosition({
x: e.clientX,
y: e.clientY
});
setContextMenuVisible(true);
}, []);
// Handle context menu close
const handleContextMenuClose = useCallback(() => {
setContextMenuVisible(false);
}, []);
// Handle click outside for task name editing
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -169,6 +193,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
e.preventDefault();
onEditTaskName(true);
}}
onContextMenu={handleContextMenu}
title={taskDisplayName}
>
{taskDisplayName}
@@ -251,6 +276,17 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
</button>
</>
)}
{/* Context Menu */}
{contextMenuVisible && createPortal(
<TaskContextMenu
task={task}
projectId={projectId}
position={contextMenuPosition}
onClose={handleContextMenuClose}
/>,
document.body
)}
</div>
);
});

View File

@@ -20,6 +20,112 @@
border-top: 1px solid #303030;
}
/* Dark mode confirmation modal styling */
.dark .ant-modal-confirm .ant-modal-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-header,
[data-theme="dark"] .ant-modal-confirm .ant-modal-header {
background-color: #1f1f1f !important;
border-bottom: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-body,
[data-theme="dark"] .ant-modal-confirm .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-footer,
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer {
background-color: #1f1f1f !important;
border-top: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.dark .ant-modal-confirm .ant-btn-default,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default {
background-color: #141414 !important;
border-color: #303030 !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-default:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover {
background-color: #262626 !important;
border-color: #40a9ff !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-primary,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary {
background-color: #1890ff !important;
border-color: #1890ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-primary:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover {
background-color: #40a9ff !important;
border-color: #40a9ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous {
background-color: #ff4d4f !important;
border-color: #ff4d4f !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover {
background-color: #ff7875 !important;
border-color: #ff7875 !important;
color: #ffffff !important;
}
/* Light mode confirmation modal styling (ensure consistency) */
.ant-modal-confirm .ant-modal-content {
background-color: #ffffff;
border: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-header {
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-body {
background-color: #ffffff;
color: #262626;
}
.ant-modal-confirm .ant-modal-footer {
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-confirm-title {
color: #262626;
}
.ant-modal-confirm .ant-modal-confirm-content {
color: #595959;
}
.dark-modal .ant-form-item-label > label {
color: #d9d9d9;
}

View File

@@ -18,6 +18,7 @@ import {
deletePhaseOption,
updatePhaseColor,
} from '@/features/projects/singleProject/phase/phases.slice';
import { updatePhaseLabel } from '@/features/project/project.slice';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { Modal as AntModal } from 'antd';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
@@ -307,7 +308,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
if (!newPhaseName.trim() || !finalProjectId) return;
try {
await dispatch(addPhaseOption({ projectId: finalProjectId }));
await dispatch(addPhaseOption({ projectId: finalProjectId, name: newPhaseName.trim() }));
await dispatch(fetchPhasesByProjectId(finalProjectId));
await refreshTasks();
setNewPhaseName('');
@@ -408,6 +409,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
).unwrap();
if (res.done) {
dispatch(updatePhaseLabel(phaseName));
setInitialPhaseName(phaseName);
await refreshTasks();
}
@@ -428,7 +430,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
<Title level={4} className={`m-0 font-semibold ${
isDarkMode ? 'text-gray-100' : 'text-gray-800'
}`}>
{t('configurePhases')}
{t('configure')} {phaseName || project?.phase_label || t('phasesText')}
</Title>
}
open={open}
@@ -495,7 +497,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
<Text className={`text-xs font-medium ${
isDarkMode ? 'text-gray-300' : 'text-blue-700'
}`}>
🎨 Drag phases to reorder them. Click on a phase name to rename it. Each phase can have a custom color.
🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to reorder them. Click on a {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it. Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a custom color.
</Text>
</div>
@@ -558,7 +560,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
<Text className={`text-xs font-medium ${
isDarkMode ? 'text-gray-300' : 'text-gray-700'
}`}>
{t('phaseOptions')}
{phaseName || project?.phase_label || t('phasesText')} {t('optionsText')}
</Text>
<Button
type="primary"
@@ -601,7 +603,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
isDarkMode ? 'text-gray-400' : 'text-gray-500'
}`}>
<Text className="text-sm font-medium">
{t('noPhasesFound')}
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} {t('found')}
</Text>
<br />
<Button

View File

@@ -20,6 +20,112 @@
border-top: 1px solid #303030;
}
/* Dark mode confirmation modal styling */
.dark .ant-modal-confirm .ant-modal-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-header,
[data-theme="dark"] .ant-modal-confirm .ant-modal-header {
background-color: #1f1f1f !important;
border-bottom: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-body,
[data-theme="dark"] .ant-modal-confirm .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-footer,
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer {
background-color: #1f1f1f !important;
border-top: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.dark .ant-modal-confirm .ant-btn-default,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default {
background-color: #141414 !important;
border-color: #303030 !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-default:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover {
background-color: #262626 !important;
border-color: #40a9ff !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-primary,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary {
background-color: #1890ff !important;
border-color: #1890ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-primary:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover {
background-color: #40a9ff !important;
border-color: #40a9ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous {
background-color: #ff4d4f !important;
border-color: #ff4d4f !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover {
background-color: #ff7875 !important;
border-color: #ff7875 !important;
color: #ffffff !important;
}
/* Light mode confirmation modal styling (ensure consistency) */
.ant-modal-confirm .ant-modal-content {
background-color: #ffffff;
border: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-header {
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-body {
background-color: #ffffff;
color: #262626;
}
.ant-modal-confirm .ant-modal-footer {
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-confirm-title {
color: #262626;
}
.ant-modal-confirm .ant-modal-confirm-content {
color: #595959;
}
.dark-modal .ant-form-item-label > label {
color: #d9d9d9;
}

View File

@@ -15,7 +15,6 @@ import { IKanbanTaskStatus } from '@/types/tasks/taskStatus.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 { fetchTaskGroups } from '@/features/tasks/tasks.slice';
import './ManageStatusModal.css';
const { Title, Text } = Typography;
@@ -594,7 +593,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
// Refresh from server to ensure consistency
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error changing status category:', error);
@@ -736,7 +734,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => {
// Refresh task lists after status order change
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
}).catch(error => {
console.error('Error updating status order:', error);
@@ -767,7 +764,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
if (res.done) {
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
}
} catch (error) {
@@ -791,7 +787,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
await statusApiService.updateNameOfStatus(id, body, finalProjectId);
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error renaming status:', error);
@@ -813,7 +808,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
await statusApiService.deleteStatus(id, finalProjectId, replacingStatusId);
dispatch(fetchStatuses(finalProjectId));
dispatch(fetchTasksV3(finalProjectId));
dispatch(fetchTaskGroups(finalProjectId));
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
} catch (error) {
console.error('Error deleting status:', error);

View File

@@ -369,6 +369,7 @@ const FilterDropdown: React.FC<{
dispatch?: any;
onManageStatus?: () => void;
onManagePhase?: () => void;
projectPhaseLabel?: string; // Add this prop
}> = ({
section,
onSelectionChange,
@@ -380,6 +381,7 @@ const FilterDropdown: React.FC<{
dispatch,
onManageStatus,
onManagePhase,
projectPhaseLabel, // Add this prop
}) => {
const { t } = useTranslation('task-list-filters');
// Add permission checks for groupBy section
@@ -495,7 +497,7 @@ const FilterDropdown: React.FC<{
isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
}`}
>
{t('managePhases')}
{t('manage')} {projectPhaseLabel || t('phasesText')}
</button>
)}
{section.selectedValues[0] === 'status' && (
@@ -994,6 +996,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
const { projectId } = useAppSelector(state => state.projectReducer);
const { projectView } = useTabSearchParam();
const projectPhaseLabel = useAppSelector(state => state.projectReducer.project?.phase_label);
// Theme-aware class names - memoize to prevent unnecessary re-renders
// Using greyish colors for both dark and light modes
@@ -1298,6 +1301,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
dispatch={dispatch}
onManageStatus={() => setShowManageStatusModal(true)}
onManagePhase={() => setShowManagePhaseModal(true)}
projectPhaseLabel={projectPhaseLabel}
/>
))
) : (

View File

@@ -312,7 +312,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
{groupTasks.length === 0 ? (
<div className="task-group-empty">
<div className="task-table-fixed-columns">
<div style={{ width: '380px', padding: '20px 12px' }}>
<div style={{ width: '380px', padding: '32px 12px' }}>
<div className="text-center text-gray-500">
<Text type="secondary">No tasks in this group</Text>
<br />
@@ -487,7 +487,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
.task-group-empty {
display: flex;
height: 80px;
height: 120px;
align-items: center;
background: var(--task-bg-primary, white);
transition: background-color 0.3s ease;

View File

@@ -35,8 +35,6 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
(phaseId: string, phaseName: string) => {
if (!task.id || !phaseId || !connected) return;
console.log('🎯 Phase change initiated:', { taskId: task.id, phaseId, phaseName });
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: task.id,
phase_id: phaseId,
@@ -51,8 +49,6 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
const handlePhaseClear = useCallback(() => {
if (!task.id || !connected) return;
console.log('🎯 Phase clear initiated:', { taskId: task.id });
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: task.id,
phase_id: null,

View File

@@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [
{ label: 'Phase', value: IGroupBy.PHASE },
];
const LOCALSTORAGE_GROUP_KEY = 'worklenz.enhanced-kanban.group_by';
const LOCALSTORAGE_GROUP_KEY = 'worklenz.kanban.group_by';
export const getCurrentGroup = (): IGroupBy => {
const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { useSelectedProject } from '../../../../hooks/useSelectedProject';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import { Flex } from 'antd';
import ConfigPhaseButton from './ConfigPhaseButton';
@@ -10,19 +9,13 @@ const PhaseHeader = () => {
// localization
const { t } = useTranslation('task-list-filters');
// get selected project for useSelectedProject hook
const selectedProject = useSelectedProject();
// get phase data from redux
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
//get phases details from phases slice
const phase = phaseList.find(el => el.projectId === selectedProject?.projectId);
// get project data from redux
const { project } = useAppSelector(state => state.projectReducer);
return (
<Flex align="center" justify="space-between">
{phase?.phase || t('phasesText')}
<ConfigPhaseButton color={colors.darkGray} />
{project?.phase_label || t('phasesText')}
<ConfigPhaseButton />
</Flex>
);
};

View File

@@ -16,9 +16,9 @@ const initialState: PhaseState = {
export const addPhaseOption = createAsyncThunk(
'phase/addPhaseOption',
async ({ projectId }: { projectId: string }, { rejectWithValue }) => {
async ({ projectId, name }: { projectId: string; name?: string }, { rejectWithValue }) => {
try {
const response = await phasesApiService.addPhaseOption(projectId);
const response = await phasesApiService.addPhaseOption(projectId, name);
return response;
} catch (error) {
return rejectWithValue(error);

View File

@@ -17,8 +17,36 @@ interface LocalGroupingState {
collapsedGroups: string[];
}
// Local storage constants
const LOCALSTORAGE_GROUP_KEY = 'worklenz.tasklist.group_by';
// Utility functions for local storage
const loadGroupingFromLocalStorage = (): GroupingType | null => {
try {
const stored = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);
if (stored && ['status', 'priority', 'phase'].includes(stored)) {
return stored as GroupingType;
}
} catch (error) {
console.warn('Failed to load grouping from localStorage:', error);
}
return 'status'; // Default to 'status' instead of null
};
const saveGroupingToLocalStorage = (grouping: GroupingType | null): void => {
try {
if (grouping) {
localStorage.setItem(LOCALSTORAGE_GROUP_KEY, grouping);
} else {
localStorage.removeItem(LOCALSTORAGE_GROUP_KEY);
}
} catch (error) {
console.warn('Failed to save grouping to localStorage:', error);
}
};
const initialState: LocalGroupingState = {
currentGrouping: null,
currentGrouping: loadGroupingFromLocalStorage(),
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
groupOrder: {
status: ['todo', 'doing', 'done'],
@@ -35,6 +63,7 @@ const groupingSlice = createSlice({
reducers: {
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
state.currentGrouping = action.payload;
saveGroupingToLocalStorage(action.payload);
},
addCustomPhase: (state, action: PayloadAction<string>) => {

View File

@@ -123,11 +123,11 @@ const TasksList: React.FC = React.memo(() => {
<span>{t('tasks.name')}</span>
</Flex>
),
width: '150px',
width: '40%',
render: (_, record) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Tooltip title={record.name}>
<Typography.Text ellipsis={{ tooltip: true }} style={{ maxWidth: 150 }}>
<Typography.Text style={{ flex: 1, marginRight: 8 }}>
{record.name}
</Typography.Text>
</Tooltip>
@@ -155,15 +155,14 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'project',
title: t('tasks.project'),
width: '120px',
width: '25%',
render: (_, record) => {
return (
<Tooltip title={record.project_name}>
<Typography.Paragraph
style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }}
ellipsis={{ tooltip: true }}
style={{ margin: 0, paddingInlineEnd: 6 }}
>
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
<Badge color={record.project_color || 'blue'} style={{ marginInlineEnd: 4 }} />
{record.project_name}
</Typography.Paragraph>
</Tooltip>
@@ -173,7 +172,7 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'status',
title: t('tasks.status'),
width: '180px',
width: '20%',
render: (_, record) => (
<HomeTasksStatusDropdown task={record} teamId={record.team_id || ''} />
),
@@ -181,7 +180,7 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'dueDate',
title: t('tasks.dueDate'),
width: '180px',
width: '15%',
dataIndex: 'end_date',
render: (_, record) => <HomeTasksDatePicker record={record} />,
},

View File

@@ -106,13 +106,8 @@ const BoardCreateSectionCard = () => {
}
if (groupBy === IGroupBy.PHASE && projectId) {
const body = {
name: sectionName,
project_id: projectId,
};
try {
const response = await phasesApiService.addPhaseOption(projectId);
const response = await phasesApiService.addPhaseOption(projectId, sectionName);
if (response.done && response.body) {
dispatch(fetchBoardTaskGroups(projectId));
}

View File

@@ -212,7 +212,7 @@ const ProjectViewHeader = memo(() => {
setCreatingTask(true);
const body: Partial<ITaskCreateRequest> = {
name: DEFAULT_TASK_NAME,
name: t('defaultTaskName'),
project_id: selectedProject.id,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
@@ -242,7 +242,7 @@ const ProjectViewHeader = memo(() => {
logger.error('Error creating task', error);
setCreatingTask(false);
}
}, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab]);
}, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab, t]);
// Memoized import task template handler
const handleImportTaskTemplate = useCallback(() => {

View File

@@ -524,6 +524,69 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}
// NEW SIMPLIFIED APPROACH: Calculate all affected task updates and send them
const taskUpdates: Array<{
task_id: string;
sort_order: number;
status_id?: string;
priority_id?: string;
phase_id?: string;
}> = [];
// Add updates for all tasks in affected groups
if (activeGroupId === overGroupId) {
// Same group - just reorder
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
updatedTasks.forEach((task, index) => {
taskUpdates.push({
task_id: task.id,
sort_order: index + 1, // 1-based indexing
});
});
} else {
// Different groups - update both source and target
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
if (isTargetGroupEmpty) {
updatedTargetTasks.push(task);
} else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) {
updatedTargetTasks.splice(toIndex, 0, task);
} else {
updatedTargetTasks.push(task);
}
// Add updates for source group
updatedSourceTasks.forEach((task, index) => {
taskUpdates.push({
task_id: task.id,
sort_order: index + 1,
});
});
// Add updates for target group (including the moved task)
updatedTargetTasks.forEach((task, index) => {
const update: any = {
task_id: task.id,
sort_order: index + 1,
};
// Add group-specific updates
if (groupBy === IGroupBy.STATUS) {
update.status_id = targetGroup.id;
} else if (groupBy === IGroupBy.PRIORITY) {
update.priority_id = targetGroup.id;
} else if (groupBy === IGroupBy.PHASE) {
update.phase_id = targetGroup.id;
}
taskUpdates.push(update);
});
}
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
@@ -534,6 +597,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
group_by: groupBy,
task: sourceGroup.tasks[fromIndex],
team_id: currentSession?.team_id,
task_updates: taskUpdates, // NEW: Send calculated updates
});
setTimeout(resetTaskRowStyles, 0);

View File

@@ -208,6 +208,18 @@ const TaskListTableWrapper = ({
>
<Flex vertical>
<Flex style={{ transform: 'translateY(6px)' }}>
{groupBy !== IGroupBy.PRIORITY &&
!showRenameInput &&
isEditable &&
name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
<Button
className="custom-collapse-button"
style={{
@@ -243,18 +255,6 @@ const TaskListTableWrapper = ({
</Typography.Text>
)}
</Button>
{groupBy !== IGroupBy.PRIORITY &&
!showRenameInput &&
isEditable &&
name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
</Flex>
<Collapsible
isOpen={isExpanded}

View File

@@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{
columnKey: string;
updateValue: (taskId: string, columnKey: string, value: string) => void;
}> = ({ selectionsList, value, task, columnKey, updateValue }) => {
// Debug the selectionsList data
const [loggedInfo, setLoggedInfo] = useState(false);
useEffect(() => {
if (!loggedInfo) {
console.log('Selection column data:', {
columnKey,
selectionsList,
});
setLoggedInfo(true);
}
}, [columnKey, selectionsList, loggedInfo]);
return (
<CustomColumnSelectionCell
@@ -1256,19 +1245,6 @@ const renderCustomColumnContent = (
);
},
selection: () => {
// Debug the selectionsList data
const [loggedInfo, setLoggedInfo] = useState(false);
useEffect(() => {
if (!loggedInfo) {
console.log('Selection column data:', {
columnKey,
selectionsList: columnObj?.selectionsList,
});
setLoggedInfo(true);
}
}, [columnKey, loggedInfo]);
return (
<SelectionFieldCell
selectionsList={columnObj?.selectionsList || []}
@@ -1650,35 +1626,12 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
const activeTask = displayTasks.find(task => task.id === active.id);
if (!activeTask) {
console.error('Active task not found:', {
activeId: active.id,
displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })),
});
return;
}
console.log('Found activeTask:', {
id: activeTask.id,
name: activeTask.name,
status_id: activeTask.status_id,
status: activeTask.status,
priority: activeTask.priority,
project_id: project?.id,
team_id: project?.team_id,
fullProject: project,
});
// Use the tableId directly as the group ID (it should be the group ID)
const currentGroupId = tableId;
console.log('Drag operation:', {
activeId: active.id,
overId: over.id,
tableId,
currentGroupId,
displayTasksLength: displayTasks.length,
});
// Check if this is a reorder within the same group
const overTask = displayTasks.find(task => task.id === over.id);
if (overTask) {
@@ -1686,36 +1639,17 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
const oldIndex = displayTasks.findIndex(task => task.id === active.id);
const newIndex = displayTasks.findIndex(task => task.id === over.id);
console.log('Reorder details:', { oldIndex, newIndex, activeTask: activeTask.name });
if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) {
// Get the actual sort_order values from the tasks
const fromSortOrder = activeTask.sort_order || oldIndex;
const overTaskAtNewIndex = displayTasks[newIndex];
const toSortOrder = overTaskAtNewIndex?.sort_order || newIndex;
console.log('Sort order details:', {
oldIndex,
newIndex,
fromSortOrder,
toSortOrder,
activeTaskSortOrder: activeTask.sort_order,
overTaskSortOrder: overTaskAtNewIndex?.sort_order,
});
// Create updated task list with reordered tasks
const updatedTasks = [...displayTasks];
const [movedTask] = updatedTasks.splice(oldIndex, 1);
updatedTasks.splice(newIndex, 0, movedTask);
console.log('Dispatching reorderTasks with:', {
activeGroupId: currentGroupId,
overGroupId: currentGroupId,
fromIndex: oldIndex,
toIndex: newIndex,
taskName: activeTask.name,
});
// Update local state immediately for better UX
dispatch(
reorderTasks({
@@ -1758,34 +1692,10 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
// Validate required fields before sending
if (!body.task.id) {
console.error('Cannot send socket event: task.id is missing', { activeTask, active });
return;
}
console.log('Validated values:', {
from_index: body.from_index,
to_index: body.to_index,
status: body.task.status,
priority: body.task.priority,
team_id: body.team_id,
originalStatus: activeTask.status_id || activeTask.status,
originalPriority: activeTask.priority,
originalTeamId: project.team_id,
sessionTeamId: currentSession?.team_id,
finalTeamId: body.team_id,
});
console.log('Sending socket event:', body);
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
} else {
console.error('Cannot send socket event: missing required data', {
hasSocket: !!socket,
hasProjectId: !!project?.id,
hasActiveId: !!active.id,
hasActiveTaskId: !!activeTask.id,
activeTask,
active,
});
}
}
}

View File

@@ -4,6 +4,152 @@
width: 100%;
}
/* Global Confirmation Modal Styles */
/* Light mode confirmation modal styling (default) */
.ant-modal-confirm .ant-modal-content {
background-color: #ffffff;
border: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-header {
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-body {
background-color: #ffffff;
color: #262626;
}
.ant-modal-confirm .ant-modal-footer {
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
}
.ant-modal-confirm .ant-modal-confirm-title {
color: #262626;
}
.ant-modal-confirm .ant-modal-confirm-content {
color: #595959;
}
/* Dark mode confirmation modal styling */
.dark .ant-modal-confirm .ant-modal-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-content,
html.dark .ant-modal-confirm .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-header,
[data-theme="dark"] .ant-modal-confirm .ant-modal-header,
html.dark .ant-modal-confirm .ant-modal-header {
background-color: #1f1f1f !important;
border-bottom: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-body,
[data-theme="dark"] .ant-modal-confirm .ant-modal-body,
html.dark .ant-modal-confirm .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-footer,
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer,
html.dark .ant-modal-confirm .ant-modal-footer {
background-color: #1f1f1f !important;
border-top: 1px solid #303030 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title,
html.dark .ant-modal-confirm .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content,
html.dark .ant-modal-confirm .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.dark .ant-modal-confirm .ant-btn-default,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default,
html.dark .ant-modal-confirm .ant-btn-default {
background-color: #141414 !important;
border-color: #303030 !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-default:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover,
html.dark .ant-modal-confirm .ant-btn-default:hover {
background-color: #262626 !important;
border-color: #40a9ff !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-confirm .ant-btn-primary,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary,
html.dark .ant-modal-confirm .ant-btn-primary {
background-color: #1890ff !important;
border-color: #1890ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-primary:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover,
html.dark .ant-modal-confirm .ant-btn-primary:hover {
background-color: #40a9ff !important;
border-color: #40a9ff !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous,
html.dark .ant-modal-confirm .ant-btn-dangerous {
background-color: #ff4d4f !important;
border-color: #ff4d4f !important;
color: #ffffff !important;
}
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover,
html.dark .ant-modal-confirm .ant-btn-dangerous:hover {
background-color: #ff7875 !important;
border-color: #ff7875 !important;
color: #ffffff !important;
}
/* Error modal specific styling */
.dark .ant-modal-error .ant-modal-content,
[data-theme="dark"] .ant-modal-error .ant-modal-content,
html.dark .ant-modal-error .ant-modal-content {
background-color: #1f1f1f !important;
border: 1px solid #303030 !important;
}
.dark .ant-modal-error .ant-modal-body,
[data-theme="dark"] .ant-modal-error .ant-modal-body,
html.dark .ant-modal-error .ant-modal-body {
background-color: #1f1f1f !important;
color: #d9d9d9 !important;
}
.dark .ant-modal-error .ant-modal-confirm-title,
[data-theme="dark"] .ant-modal-error .ant-modal-confirm-title,
html.dark .ant-modal-error .ant-modal-confirm-title {
color: #d9d9d9 !important;
}
.dark .ant-modal-error .ant-modal-confirm-content,
[data-theme="dark"] .ant-modal-error .ant-modal-content,
html.dark .ant-modal-error .ant-modal-confirm-content {
color: #8c8c8c !important;
}
.task-group {
transition: all 0.2s ease;
}