Merge pull request #225 from Worklenz/fix/WB-705-task-list-timer-cell
Fix/wb 705 task list timer cell
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
Worklenz is a project management application built with React, TypeScript, and Ant Design. The project is bundled using [Vite](https://vitejs.dev/).
|
Worklenz is a project management application built with React, TypeScript, and Ant Design. The project is bundled using [Vite](https://vitejs.dev/).
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Available Scripts](#available-scripts)
|
- [Available Scripts](#available-scripts)
|
||||||
- [Project Structure](#project-structure)
|
- [Project Structure](#project-structure)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"configurePhases": "Configure Phases",
|
"configurePhases": "Configure Phases",
|
||||||
"phaseLabel": "Phase Label",
|
"phaseLabel": "Phase Label",
|
||||||
"enterPhaseName": "Enter a name for phase label",
|
"enterPhaseName": "Enter a name for phase label",
|
||||||
"addOption": "Add Option",
|
"addOption": "Add Option",
|
||||||
"phaseOptions": "Phase Options:"
|
"phaseOptions": "Phase Options:"
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"importTaskTemplate": "Import Task Template",
|
"importTaskTemplate": "Import Task Template",
|
||||||
"templateName": "Template Name",
|
"templateName": "Template Name",
|
||||||
"templateDescription": "Template Description",
|
"templateDescription": "Template Description",
|
||||||
"selectedTasks": "Selected Tasks",
|
"selectedTasks": "Selected Tasks",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"import": "Import"
|
"import": "Import"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Project Members",
|
"title": "Project Members",
|
||||||
"searchLabel": "Add members by adding their name or email",
|
"searchLabel": "Add members by adding their name or email",
|
||||||
"searchPlaceholder": "Type name or email",
|
"searchPlaceholder": "Type name or email",
|
||||||
"inviteAsAMember": "Invite as a member",
|
"inviteAsAMember": "Invite as a member",
|
||||||
"inviteNewMemberByEmail": "Invite new member by email"
|
"inviteNewMemberByEmail": "Invite new member by email"
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"importTasks": "Import tasks",
|
"importTasks": "Import tasks",
|
||||||
"importTask": "Import task",
|
"importTask": "Import task",
|
||||||
"createTask": "Create task",
|
"createTask": "Create task",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"unsubscribe": "Unsubscribe",
|
"unsubscribe": "Unsubscribe",
|
||||||
"deleteProject": "Delete project",
|
"deleteProject": "Delete project",
|
||||||
"startDate": "Start date",
|
"startDate": "Start date",
|
||||||
"endDate": "End date",
|
"endDate": "End date",
|
||||||
"projectSettings": "Project settings",
|
"projectSettings": "Project settings",
|
||||||
"projectSummary": "Project summary",
|
"projectSummary": "Project summary",
|
||||||
"receiveProjectSummary": "Receive a project summary every evening.",
|
"receiveProjectSummary": "Receive a project summary every evening.",
|
||||||
"refreshProject": "Refresh project",
|
"refreshProject": "Refresh project",
|
||||||
"saveAsTemplate": "Save as template",
|
"saveAsTemplate": "Save as template",
|
||||||
"invite": "Invite"
|
"invite": "Invite"
|
||||||
}
|
}
|
||||||
@@ -1,41 +1,41 @@
|
|||||||
{
|
{
|
||||||
"taskSelected": "task selected",
|
"taskSelected": "task selected",
|
||||||
"tasksSelected": "tasks selected",
|
"tasksSelected": "tasks selected",
|
||||||
"changeStatus": "Change Status/ Prioriy/ Phases",
|
"changeStatus": "Change Status/ Prioriy/ Phases",
|
||||||
"changeLabel": "Change Label",
|
"changeLabel": "Change Label",
|
||||||
"assignToMe": "Assign to me",
|
"assignToMe": "Assign to me",
|
||||||
"changeAssignees": "Change Assignees",
|
"changeAssignees": "Change Assignees",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"moreOptions": "More options",
|
"moreOptions": "More options",
|
||||||
"deselectAll": "Deselect all",
|
"deselectAll": "Deselect all",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"phase": "Phase",
|
"phase": "Phase",
|
||||||
"member": "Member",
|
"member": "Member",
|
||||||
"createTaskTemplate": "Create Task Template",
|
"createTaskTemplate": "Create Task Template",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"createLabel": "+ Create Label",
|
"createLabel": "+ Create Label",
|
||||||
"searchOrCreateLabel": "Search or create label...",
|
"searchOrCreateLabel": "Search or create label...",
|
||||||
"hitEnterToCreate": "Press Enter to create",
|
"hitEnterToCreate": "Press Enter to create",
|
||||||
"labelExists": "Label already exists",
|
"labelExists": "Label already exists",
|
||||||
"pendingInvitation": "Pending Invitation",
|
"pendingInvitation": "Pending Invitation",
|
||||||
"noMatchingLabels": "No matching labels",
|
"noMatchingLabels": "No matching labels",
|
||||||
"noLabels": "No labels",
|
"noLabels": "No labels",
|
||||||
"CHANGE_STATUS": "Change Status",
|
"CHANGE_STATUS": "Change Status",
|
||||||
"CHANGE_PRIORITY": "Change Priority",
|
"CHANGE_PRIORITY": "Change Priority",
|
||||||
"CHANGE_PHASE": "Change Phase",
|
"CHANGE_PHASE": "Change Phase",
|
||||||
"ADD_LABELS": "Add Labels",
|
"ADD_LABELS": "Add Labels",
|
||||||
"ASSIGN_TO_ME": "Assign to Me",
|
"ASSIGN_TO_ME": "Assign to Me",
|
||||||
"ASSIGN_MEMBERS": "Assign Members",
|
"ASSIGN_MEMBERS": "Assign Members",
|
||||||
"ARCHIVE": "Archive",
|
"ARCHIVE": "Archive",
|
||||||
"DELETE": "Delete",
|
"DELETE": "Delete",
|
||||||
"CANCEL": "Cancel",
|
"CANCEL": "Cancel",
|
||||||
"CLEAR_SELECTION": "Clear Selection",
|
"CLEAR_SELECTION": "Clear Selection",
|
||||||
"TASKS_SELECTED": "{{count}} task selected",
|
"TASKS_SELECTED": "{{count}} task selected",
|
||||||
"TASKS_SELECTED_plural": "{{count}} tasks selected",
|
"TASKS_SELECTED_plural": "{{count}} tasks selected",
|
||||||
"DELETE_TASKS_CONFIRM": "Delete {{count}} task?",
|
"DELETE_TASKS_CONFIRM": "Delete {{count}} task?",
|
||||||
"DELETE_TASKS_CONFIRM_plural": "Delete {{count}} tasks?",
|
"DELETE_TASKS_CONFIRM_plural": "Delete {{count}} tasks?",
|
||||||
"DELETE_TASKS_WARNING": "This action cannot be undone."
|
"DELETE_TASKS_WARNING": "This action cannot be undone."
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Unauthorized!",
|
"title": "Unauthorized!",
|
||||||
"subtitle": "You are not authorized to access this page",
|
"subtitle": "You are not authorized to access this page",
|
||||||
"button": "Go to Home"
|
"button": "Go to Home"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"configurePhases": "Configurar fases",
|
"configurePhases": "Configurar fases",
|
||||||
"phaseLabel": "Etiqueta de fase",
|
"phaseLabel": "Etiqueta de fase",
|
||||||
"enterPhaseName": "Ingrese un nombre para la etiqueta de fase",
|
"enterPhaseName": "Ingrese un nombre para la etiqueta de fase",
|
||||||
"addOption": "Agregar opción",
|
"addOption": "Agregar opción",
|
||||||
"phaseOptions": "Opciones de fase:"
|
"phaseOptions": "Opciones de fase:"
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"importTaskTemplate": "Importar plantilla de tarea",
|
"importTaskTemplate": "Importar plantilla de tarea",
|
||||||
"templateName": "Nombre de la plantilla",
|
"templateName": "Nombre de la plantilla",
|
||||||
"templateDescription": "Descripción de la plantilla",
|
"templateDescription": "Descripción de la plantilla",
|
||||||
"selectedTasks": "Tareas seleccionadas",
|
"selectedTasks": "Tareas seleccionadas",
|
||||||
"tasks": "Tareas",
|
"tasks": "Tareas",
|
||||||
"templates": "Plantillas",
|
"templates": "Plantillas",
|
||||||
"remove": "Eliminar",
|
"remove": "Eliminar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"import": "Importar"
|
"import": "Importar"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Miembros del Proyecto",
|
"title": "Miembros del Proyecto",
|
||||||
"searchLabel": "Agregar miembros ingresando su nombre o correo electrónico",
|
"searchLabel": "Agregar miembros ingresando su nombre o correo electrónico",
|
||||||
"searchPlaceholder": "Escriba nombre o correo electrónico",
|
"searchPlaceholder": "Escriba nombre o correo electrónico",
|
||||||
"inviteAsAMember": "Invitar como miembro",
|
"inviteAsAMember": "Invitar como miembro",
|
||||||
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico"
|
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico"
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"importTasks": "Importar tareas",
|
"importTasks": "Importar tareas",
|
||||||
"importTask": "Importar tarea",
|
"importTask": "Importar tarea",
|
||||||
"createTask": "Crear tarea",
|
"createTask": "Crear tarea",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"subscribe": "Suscribirse",
|
"subscribe": "Suscribirse",
|
||||||
"unsubscribe": "Cancelar suscripción",
|
"unsubscribe": "Cancelar suscripción",
|
||||||
"deleteProject": "Eliminar proyecto",
|
"deleteProject": "Eliminar proyecto",
|
||||||
"startDate": "Fecha de inicio",
|
"startDate": "Fecha de inicio",
|
||||||
"endDate": "Fecha de finalización",
|
"endDate": "Fecha de finalización",
|
||||||
"projectSettings": "Ajustes del proyecto",
|
"projectSettings": "Ajustes del proyecto",
|
||||||
"projectSummary": "Resumen del proyecto",
|
"projectSummary": "Resumen del proyecto",
|
||||||
"receiveProjectSummary": "Recibir un resumen del proyecto todas las noches.",
|
"receiveProjectSummary": "Recibir un resumen del proyecto todas las noches.",
|
||||||
"refreshProject": "Actualizar proyecto",
|
"refreshProject": "Actualizar proyecto",
|
||||||
"saveAsTemplate": "Guardar como plantilla",
|
"saveAsTemplate": "Guardar como plantilla",
|
||||||
"invite": "Invitar"
|
"invite": "Invitar"
|
||||||
}
|
}
|
||||||
@@ -1,41 +1,41 @@
|
|||||||
{
|
{
|
||||||
"taskSelected": "Tarea seleccionada",
|
"taskSelected": "Tarea seleccionada",
|
||||||
"tasksSelected": "Tareas seleccionadas",
|
"tasksSelected": "Tareas seleccionadas",
|
||||||
"changeStatus": "Cambiar estado/ prioridad/ fases",
|
"changeStatus": "Cambiar estado/ prioridad/ fases",
|
||||||
"changeLabel": "Cambiar etiqueta",
|
"changeLabel": "Cambiar etiqueta",
|
||||||
"assignToMe": "Asignar a mí",
|
"assignToMe": "Asignar a mí",
|
||||||
"changeAssignees": "Cambiar asignados",
|
"changeAssignees": "Cambiar asignados",
|
||||||
"archive": "Archivar",
|
"archive": "Archivar",
|
||||||
"unarchive": "Desarchivar",
|
"unarchive": "Desarchivar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"moreOptions": "Más opciones",
|
"moreOptions": "Más opciones",
|
||||||
"deselectAll": "Deseleccionar todo",
|
"deselectAll": "Deseleccionar todo",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"priority": "Prioridad",
|
"priority": "Prioridad",
|
||||||
"phase": "Fase",
|
"phase": "Fase",
|
||||||
"member": "Miembro",
|
"member": "Miembro",
|
||||||
"createTaskTemplate": "Crear plantilla de tarea",
|
"createTaskTemplate": "Crear plantilla de tarea",
|
||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
"createLabel": "+ Crear etiqueta",
|
"createLabel": "+ Crear etiqueta",
|
||||||
"searchOrCreateLabel": "Buscar o crear etiqueta...",
|
"searchOrCreateLabel": "Buscar o crear etiqueta...",
|
||||||
"hitEnterToCreate": "Presione Enter para crear",
|
"hitEnterToCreate": "Presione Enter para crear",
|
||||||
"labelExists": "La etiqueta ya existe",
|
"labelExists": "La etiqueta ya existe",
|
||||||
"pendingInvitation": "Invitación Pendiente",
|
"pendingInvitation": "Invitación Pendiente",
|
||||||
"noMatchingLabels": "No hay etiquetas coincidentes",
|
"noMatchingLabels": "No hay etiquetas coincidentes",
|
||||||
"noLabels": "Sin etiquetas",
|
"noLabels": "Sin etiquetas",
|
||||||
"CHANGE_STATUS": "Cambiar Estado",
|
"CHANGE_STATUS": "Cambiar Estado",
|
||||||
"CHANGE_PRIORITY": "Cambiar Prioridad",
|
"CHANGE_PRIORITY": "Cambiar Prioridad",
|
||||||
"CHANGE_PHASE": "Cambiar Fase",
|
"CHANGE_PHASE": "Cambiar Fase",
|
||||||
"ADD_LABELS": "Agregar Etiquetas",
|
"ADD_LABELS": "Agregar Etiquetas",
|
||||||
"ASSIGN_TO_ME": "Asignar a Mí",
|
"ASSIGN_TO_ME": "Asignar a Mí",
|
||||||
"ASSIGN_MEMBERS": "Asignar Miembros",
|
"ASSIGN_MEMBERS": "Asignar Miembros",
|
||||||
"ARCHIVE": "Archivar",
|
"ARCHIVE": "Archivar",
|
||||||
"DELETE": "Eliminar",
|
"DELETE": "Eliminar",
|
||||||
"CANCEL": "Cancelar",
|
"CANCEL": "Cancelar",
|
||||||
"CLEAR_SELECTION": "Limpiar Selección",
|
"CLEAR_SELECTION": "Limpiar Selección",
|
||||||
"TASKS_SELECTED": "{{count}} tarea seleccionada",
|
"TASKS_SELECTED": "{{count}} tarea seleccionada",
|
||||||
"TASKS_SELECTED_plural": "{{count}} tareas seleccionadas",
|
"TASKS_SELECTED_plural": "{{count}} tareas seleccionadas",
|
||||||
"DELETE_TASKS_CONFIRM": "¿Eliminar {{count}} tarea?",
|
"DELETE_TASKS_CONFIRM": "¿Eliminar {{count}} tarea?",
|
||||||
"DELETE_TASKS_CONFIRM_plural": "¿Eliminar {{count}} tareas?",
|
"DELETE_TASKS_CONFIRM_plural": "¿Eliminar {{count}} tareas?",
|
||||||
"DELETE_TASKS_WARNING": "Esta acción no se puede deshacer."
|
"DELETE_TASKS_WARNING": "Esta acción no se puede deshacer."
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "¡No autorizado!",
|
"title": "¡No autorizado!",
|
||||||
"subtitle": "No tienes permisos para acceder a esta página",
|
"subtitle": "No tienes permisos para acceder a esta página",
|
||||||
"button": "Ir a Inicio"
|
"button": "Ir a Inicio"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"configurePhases": "Configurar fases",
|
"configurePhases": "Configurar fases",
|
||||||
"phaseLabel": "Etiqueta de fase",
|
"phaseLabel": "Etiqueta de fase",
|
||||||
"enterPhaseName": "Ingrese un nombre para la etiqueta de fase",
|
"enterPhaseName": "Ingrese un nombre para la etiqueta de fase",
|
||||||
"addOption": "Agregar opción",
|
"addOption": "Agregar opción",
|
||||||
"phaseOptions": "Opciones de fase:"
|
"phaseOptions": "Opciones de fase:"
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"importTaskTemplate": "Importar modelo de tarefa",
|
"importTaskTemplate": "Importar modelo de tarefa",
|
||||||
"templateName": "Nome do modelo",
|
"templateName": "Nome do modelo",
|
||||||
"templateDescription": "Descrição do modelo",
|
"templateDescription": "Descrição do modelo",
|
||||||
"selectedTasks": "Tarefas selecionadas",
|
"selectedTasks": "Tarefas selecionadas",
|
||||||
"tasks": "Tarefas",
|
"tasks": "Tarefas",
|
||||||
"templates": "Modelos",
|
"templates": "Modelos",
|
||||||
"remove": "Remover",
|
"remove": "Remover",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"import": "Importar"
|
"import": "Importar"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Membros do Projeto",
|
"title": "Membros do Projeto",
|
||||||
"searchLabel": "Adicionar membros inserindo nome ou e-mail",
|
"searchLabel": "Adicionar membros inserindo nome ou e-mail",
|
||||||
"searchPlaceholder": "Digite nome ou e-mail",
|
"searchPlaceholder": "Digite nome ou e-mail",
|
||||||
"inviteAsAMember": "Convidar como membro",
|
"inviteAsAMember": "Convidar como membro",
|
||||||
"inviteNewMemberByEmail": "Convidar novo membro por e-mail"
|
"inviteNewMemberByEmail": "Convidar novo membro por e-mail"
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"importTasks": "Importar tarefas",
|
"importTasks": "Importar tarefas",
|
||||||
"importTask": "Importar tarefa",
|
"importTask": "Importar tarefa",
|
||||||
"createTask": "Criar tarefa",
|
"createTask": "Criar tarefa",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"subscribe": "Inscrever-se",
|
"subscribe": "Inscrever-se",
|
||||||
"unsubscribe": "Cancelar inscrição",
|
"unsubscribe": "Cancelar inscrição",
|
||||||
"deleteProject": "Excluir projeto",
|
"deleteProject": "Excluir projeto",
|
||||||
"startDate": "Data de início",
|
"startDate": "Data de início",
|
||||||
"endDate": "Data de fim",
|
"endDate": "Data de fim",
|
||||||
"projectSettings": "Configurações do projeto",
|
"projectSettings": "Configurações do projeto",
|
||||||
"projectSummary": "Resumo do projeto",
|
"projectSummary": "Resumo do projeto",
|
||||||
"receiveProjectSummary": "Receber um resumo do projeto todas as noites.",
|
"receiveProjectSummary": "Receber um resumo do projeto todas as noites.",
|
||||||
"refreshProject": "Atualizar projeto",
|
"refreshProject": "Atualizar projeto",
|
||||||
"saveAsTemplate": "Salvar como modelo",
|
"saveAsTemplate": "Salvar como modelo",
|
||||||
"invite": "Convidar"
|
"invite": "Convidar"
|
||||||
}
|
}
|
||||||
@@ -1,41 +1,41 @@
|
|||||||
{
|
{
|
||||||
"taskSelected": "Tarefa selecionada",
|
"taskSelected": "Tarefa selecionada",
|
||||||
"tasksSelected": "Tarefas selecionadas",
|
"tasksSelected": "Tarefas selecionadas",
|
||||||
"changeStatus": "Alterar Status/ Prioridade/ Fases",
|
"changeStatus": "Alterar Status/ Prioridade/ Fases",
|
||||||
"changeLabel": "Alterar Etiqueta",
|
"changeLabel": "Alterar Etiqueta",
|
||||||
"assignToMe": "Atribuir a mim",
|
"assignToMe": "Atribuir a mim",
|
||||||
"changeAssignees": "Alterar Assignados",
|
"changeAssignees": "Alterar Assignados",
|
||||||
"archive": "Arquivar",
|
"archive": "Arquivar",
|
||||||
"unarchive": "Desarquivar",
|
"unarchive": "Desarquivar",
|
||||||
"delete": "Deletar",
|
"delete": "Deletar",
|
||||||
"moreOptions": "Mais opções",
|
"moreOptions": "Mais opções",
|
||||||
"deselectAll": "Desmarcar todas",
|
"deselectAll": "Desmarcar todas",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"priority": "Prioridade",
|
"priority": "Prioridade",
|
||||||
"phase": "Fase",
|
"phase": "Fase",
|
||||||
"member": "Membro",
|
"member": "Membro",
|
||||||
"createTaskTemplate": "Criar Modelo de Tarefa",
|
"createTaskTemplate": "Criar Modelo de Tarefa",
|
||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
"createLabel": "+ Criar etiqueta",
|
"createLabel": "+ Criar etiqueta",
|
||||||
"searchOrCreateLabel": "Pesquisar ou criar etiqueta...",
|
"searchOrCreateLabel": "Pesquisar ou criar etiqueta...",
|
||||||
"hitEnterToCreate": "Pressione Enter para criar",
|
"hitEnterToCreate": "Pressione Enter para criar",
|
||||||
"labelExists": "A etiqueta já existe",
|
"labelExists": "A etiqueta já existe",
|
||||||
"pendingInvitation": "Convite Pendente",
|
"pendingInvitation": "Convite Pendente",
|
||||||
"noMatchingLabels": "Nenhuma etiqueta correspondente",
|
"noMatchingLabels": "Nenhuma etiqueta correspondente",
|
||||||
"noLabels": "Sem etiquetas",
|
"noLabels": "Sem etiquetas",
|
||||||
"CHANGE_STATUS": "Alterar Status",
|
"CHANGE_STATUS": "Alterar Status",
|
||||||
"CHANGE_PRIORITY": "Alterar Prioridade",
|
"CHANGE_PRIORITY": "Alterar Prioridade",
|
||||||
"CHANGE_PHASE": "Alterar Fase",
|
"CHANGE_PHASE": "Alterar Fase",
|
||||||
"ADD_LABELS": "Adicionar Etiquetas",
|
"ADD_LABELS": "Adicionar Etiquetas",
|
||||||
"ASSIGN_TO_ME": "Atribuir a Mim",
|
"ASSIGN_TO_ME": "Atribuir a Mim",
|
||||||
"ASSIGN_MEMBERS": "Atribuir Membros",
|
"ASSIGN_MEMBERS": "Atribuir Membros",
|
||||||
"ARCHIVE": "Arquivar",
|
"ARCHIVE": "Arquivar",
|
||||||
"DELETE": "Deletar",
|
"DELETE": "Deletar",
|
||||||
"CANCEL": "Cancelar",
|
"CANCEL": "Cancelar",
|
||||||
"CLEAR_SELECTION": "Limpar Seleção",
|
"CLEAR_SELECTION": "Limpar Seleção",
|
||||||
"TASKS_SELECTED": "{{count}} tarefa selecionada",
|
"TASKS_SELECTED": "{{count}} tarefa selecionada",
|
||||||
"TASKS_SELECTED_plural": "{{count}} tarefas selecionadas",
|
"TASKS_SELECTED_plural": "{{count}} tarefas selecionadas",
|
||||||
"DELETE_TASKS_CONFIRM": "Deletar {{count}} tarefa?",
|
"DELETE_TASKS_CONFIRM": "Deletar {{count}} tarefa?",
|
||||||
"DELETE_TASKS_CONFIRM_plural": "Deletar {{count}} tarefas?",
|
"DELETE_TASKS_CONFIRM_plural": "Deletar {{count}} tarefas?",
|
||||||
"DELETE_TASKS_WARNING": "Esta ação não pode ser desfeita."
|
"DELETE_TASKS_WARNING": "Esta ação não pode ser desfeita."
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "¡Não autorizado!",
|
"title": "¡Não autorizado!",
|
||||||
"subtitle": "Você não tem permissão para acessar esta página",
|
"subtitle": "Você não tem permissão para acessar esta página",
|
||||||
"button": "Ir para Início"
|
"button": "Ir para Início"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
// Check if we've already attempted to unregister in this session
|
// Check if we've already attempted to unregister in this session
|
||||||
if (!sessionStorage.getItem('swUnregisterAttempted')) {
|
if (!sessionStorage.getItem('swUnregisterAttempted')) {
|
||||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
navigator.serviceWorker.getRegistrations().then(function (registrations) {
|
||||||
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
|
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
|
||||||
|
|
||||||
if (ngswWorker) {
|
if (ngswWorker) {
|
||||||
@@ -14,7 +14,7 @@ if ('serviceWorker' in navigator) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// If no ngsw-worker is found, unregister any other service workers
|
// If no ngsw-worker is found, unregister any other service workers
|
||||||
for(let registration of registrations) {
|
for (let registration of registrations) {
|
||||||
registration.unregister();
|
registration.unregister();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function copyFolderRecursiveSync(source, target) {
|
|||||||
// Copy files
|
// Copy files
|
||||||
if (fs.lstatSync(source).isDirectory()) {
|
if (fs.lstatSync(source).isDirectory()) {
|
||||||
const files = fs.readdirSync(source);
|
const files = fs.readdirSync(source);
|
||||||
files.forEach(function(file) {
|
files.forEach(function (file) {
|
||||||
const curSource = path.join(source, file);
|
const curSource = path.join(source, file);
|
||||||
if (fs.lstatSync(curSource).isDirectory()) {
|
if (fs.lstatSync(curSource).isDirectory()) {
|
||||||
copyFolderRecursiveSync(curSource, targetFolder);
|
copyFolderRecursiveSync(curSource, targetFolder);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const App: React.FC = memo(() => {
|
|||||||
<RouterProvider
|
<RouterProvider
|
||||||
router={router}
|
router={router}
|
||||||
future={{
|
future={{
|
||||||
v7_startTransition: true
|
v7_startTransition: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ThemeWrapper>
|
</ThemeWrapper>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const adminCenterApiService = {
|
|||||||
|
|
||||||
async updateTeam(
|
async updateTeam(
|
||||||
team_id: string,
|
team_id: string,
|
||||||
body: {name: string, teamMembers: IOrganizationUser[]}
|
body: { name: string; teamMembers: IOrganizationUser[] }
|
||||||
): Promise<IServerResponse<IOrganization>> {
|
): Promise<IServerResponse<IOrganization>> {
|
||||||
const response = await apiClient.put<IServerResponse<IOrganization>>(
|
const response = await apiClient.put<IServerResponse<IOrganization>>(
|
||||||
`${rootUrl}/organization/team/${team_id}`,
|
`${rootUrl}/organization/team/${team_id}`,
|
||||||
@@ -152,7 +152,6 @@ export const adminCenterApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// Billing - Configuration
|
// Billing - Configuration
|
||||||
async getCountries(): Promise<IServerResponse<IBillingConfigurationCountry[]>> {
|
async getCountries(): Promise<IServerResponse<IBillingConfigurationCountry[]>> {
|
||||||
const response = await apiClient.get<IServerResponse<IBillingConfigurationCountry[]>>(
|
const response = await apiClient.get<IServerResponse<IBillingConfigurationCountry[]>>(
|
||||||
@@ -168,7 +167,9 @@ export const adminCenterApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateBillingConfiguration(body: IBillingConfiguration): Promise<IServerResponse<IBillingConfiguration>> {
|
async updateBillingConfiguration(
|
||||||
|
body: IBillingConfiguration
|
||||||
|
): Promise<IServerResponse<IBillingConfiguration>> {
|
||||||
const response = await apiClient.put<IServerResponse<IBillingConfiguration>>(
|
const response = await apiClient.put<IServerResponse<IBillingConfiguration>>(
|
||||||
`${rootUrl}/billing/configuration`,
|
`${rootUrl}/billing/configuration`,
|
||||||
body
|
body
|
||||||
@@ -178,42 +179,58 @@ export const adminCenterApiService = {
|
|||||||
|
|
||||||
// Billing - Current Bill
|
// Billing - Current Bill
|
||||||
async getCharges(): Promise<IServerResponse<IBillingChargesResponse>> {
|
async getCharges(): Promise<IServerResponse<IBillingChargesResponse>> {
|
||||||
const response = await apiClient.get<IServerResponse<IBillingChargesResponse>>(`${rootUrl}/billing/charges`);
|
const response = await apiClient.get<IServerResponse<IBillingChargesResponse>>(
|
||||||
|
`${rootUrl}/billing/charges`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTransactions(): Promise<IServerResponse<IBillingTransaction[]>> {
|
async getTransactions(): Promise<IServerResponse<IBillingTransaction[]>> {
|
||||||
const response = await apiClient.get<IServerResponse<IBillingTransaction[]>>(`${rootUrl}/billing/transactions`);
|
const response = await apiClient.get<IServerResponse<IBillingTransaction[]>>(
|
||||||
|
`${rootUrl}/billing/transactions`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBillingAccountInfo(): Promise<IServerResponse<IBillingAccountInfo>> {
|
async getBillingAccountInfo(): Promise<IServerResponse<IBillingAccountInfo>> {
|
||||||
const response = await apiClient.get<IServerResponse<IBillingAccountInfo>>(`${rootUrl}/billing/info`);
|
const response = await apiClient.get<IServerResponse<IBillingAccountInfo>>(
|
||||||
|
`${rootUrl}/billing/info`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getFreePlanSettings(): Promise<IServerResponse<IFreePlanSettings>> {
|
async getFreePlanSettings(): Promise<IServerResponse<IFreePlanSettings>> {
|
||||||
const response = await apiClient.get<IServerResponse<IFreePlanSettings>>(`${rootUrl}/billing/free-plan`);
|
const response = await apiClient.get<IServerResponse<IFreePlanSettings>>(
|
||||||
|
`${rootUrl}/billing/free-plan`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async upgradePlan(plan: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
async upgradePlan(plan: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
||||||
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(`${rootUrl}/billing/upgrade-plan${toQueryString({plan})}`);
|
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
|
||||||
|
`${rootUrl}/billing/upgrade-plan${toQueryString({ plan })}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async changePlan(plan: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
async changePlan(plan: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
||||||
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(`${rootUrl}/billing/change-plan${toQueryString({plan})}`);
|
const response = await apiClient.get<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
|
||||||
|
`${rootUrl}/billing/change-plan${toQueryString({ plan })}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPlans(): Promise<IServerResponse<IPricingPlans>> {
|
async getPlans(): Promise<IServerResponse<IPricingPlans>> {
|
||||||
const response = await apiClient.get<IServerResponse<IPricingPlans>>(`${rootUrl}/billing/plans`);
|
const response = await apiClient.get<IServerResponse<IPricingPlans>>(
|
||||||
|
`${rootUrl}/billing/plans`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getStorageInfo(): Promise<IServerResponse<IStorageInfo>> {
|
async getStorageInfo(): Promise<IServerResponse<IStorageInfo>> {
|
||||||
const response = await apiClient.get<IServerResponse<IStorageInfo>>(`${rootUrl}/billing/storage`);
|
const response = await apiClient.get<IServerResponse<IStorageInfo>>(
|
||||||
|
`${rootUrl}/billing/storage`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -233,26 +250,34 @@ export const adminCenterApiService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addMoreSeats(totalSeats: number): Promise<IServerResponse<any>> {
|
async addMoreSeats(totalSeats: number): Promise<IServerResponse<any>> {
|
||||||
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/billing/purchase-more-seats`, {seatCount: totalSeats});
|
const response = await apiClient.post<IServerResponse<any>>(
|
||||||
|
`${rootUrl}/billing/purchase-more-seats`,
|
||||||
|
{ seatCount: totalSeats }
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async redeemCode(code: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
async redeemCode(code: string): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
||||||
const response = await apiClient.post<IServerResponse<IUpgradeSubscriptionPlanResponse>>(`${rootUrl}/billing/redeem`, {
|
const response = await apiClient.post<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
|
||||||
code,
|
`${rootUrl}/billing/redeem`,
|
||||||
});
|
{
|
||||||
|
code,
|
||||||
|
}
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAccountStorage(): Promise<IServerResponse<IBillingAccountStorage>> {
|
async getAccountStorage(): Promise<IServerResponse<IBillingAccountStorage>> {
|
||||||
const response = await apiClient.get<IServerResponse<IBillingAccountStorage>>(`${rootUrl}/billing/account-storage`);
|
const response = await apiClient.get<IServerResponse<IBillingAccountStorage>>(
|
||||||
|
`${rootUrl}/billing/account-storage`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async switchToFreePlan(teamId: string): Promise<IServerResponse<any>> {
|
async switchToFreePlan(teamId: string): Promise<IServerResponse<any>> {
|
||||||
const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/switch-to-free-plan/${teamId}`);
|
const response = await apiClient.get<IServerResponse<any>>(
|
||||||
|
`${rootUrl}/billing/switch-to-free-plan/${teamId}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { IUpgradeSubscriptionPlanResponse } from '@/types/admin-center/admin-cen
|
|||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/billing`;
|
const rootUrl = `${API_BASE_URL}/billing`;
|
||||||
export const billingApiService = {
|
export const billingApiService = {
|
||||||
async upgradeToPaidPlan(plan: string, seatCount: number): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
async upgradeToPaidPlan(
|
||||||
|
plan: string,
|
||||||
|
seatCount: number
|
||||||
|
): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
||||||
const q = toQueryString({ plan, seatCount });
|
const q = toQueryString({ plan, seatCount });
|
||||||
const response = await apiClient.get<IServerResponse<any>>(
|
const response = await apiClient.get<IServerResponse<any>>(
|
||||||
`${rootUrl}/upgrade-to-paid-plan${q}`
|
`${rootUrl}/upgrade-to-paid-plan${q}`
|
||||||
@@ -14,7 +17,9 @@ export const billingApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async purchaseMoreSeats(seatCount: number): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
async purchaseMoreSeats(
|
||||||
|
seatCount: number
|
||||||
|
): Promise<IServerResponse<IUpgradeSubscriptionPlanResponse>> {
|
||||||
const response = await apiClient.post<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
|
const response = await apiClient.post<IServerResponse<IUpgradeSubscriptionPlanResponse>>(
|
||||||
`${rootUrl}/purchase-more-seats`,
|
`${rootUrl}/purchase-more-seats`,
|
||||||
{ seatCount }
|
{ seatCount }
|
||||||
@@ -27,9 +32,5 @@ export const billingApiService = {
|
|||||||
`${rootUrl}/contact-us${toQueryString({ contactNo })}`
|
`${rootUrl}/contact-us${toQueryString({ contactNo })}`
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const refreshCsrfToken = async (): Promise<string | null> => {
|
|||||||
// Make a GET request to the server to get a fresh CSRF token with timeout
|
// Make a GET request to the server to get a fresh CSRF token with timeout
|
||||||
const response = await axios.get(`${config.apiUrl}/csrf-token`, {
|
const response = await axios.get(`${config.apiUrl}/csrf-token`, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
timeout: 10000 // 10 second timeout for CSRF token requests
|
timeout: 10000, // 10 second timeout for CSRF token requests
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenEnd = performance.now();
|
const tokenEnd = performance.now();
|
||||||
@@ -114,12 +114,15 @@ apiClient.interceptors.response.use(
|
|||||||
const errorResponse = error.response;
|
const errorResponse = error.response;
|
||||||
|
|
||||||
// Handle CSRF token errors
|
// Handle CSRF token errors
|
||||||
if (errorResponse?.status === 403 &&
|
if (
|
||||||
(typeof errorResponse.data === 'object' &&
|
errorResponse?.status === 403 &&
|
||||||
errorResponse.data !== null &&
|
((typeof errorResponse.data === 'object' &&
|
||||||
'message' in errorResponse.data &&
|
errorResponse.data !== null &&
|
||||||
(errorResponse.data.message === 'invalid csrf token' || errorResponse.data.message === 'Invalid CSRF token') ||
|
'message' in errorResponse.data &&
|
||||||
(error as any).code === 'EBADCSRFTOKEN')) {
|
(errorResponse.data.message === 'invalid csrf token' ||
|
||||||
|
errorResponse.data.message === 'Invalid CSRF token')) ||
|
||||||
|
(error as any).code === 'EBADCSRFTOKEN')
|
||||||
|
) {
|
||||||
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
|
alertService.error('Security Error', 'Invalid security token. Refreshing your session...');
|
||||||
|
|
||||||
// Try to refresh the CSRF token and retry the request
|
// Try to refresh the CSRF token and retry the request
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
import { IServerResponse } from "@/types/common.types";
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IProjectAttachmentsViewModel } from "@/types/tasks/task-attachment-view-model";
|
import { IProjectAttachmentsViewModel } from '@/types/tasks/task-attachment-view-model';
|
||||||
import apiClient from "../api-client";
|
import apiClient from '../api-client';
|
||||||
import { API_BASE_URL } from "@/shared/constants";
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import { toQueryString } from "@/utils/toQueryString";
|
import { toQueryString } from '@/utils/toQueryString';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/attachments`;
|
const rootUrl = `${API_BASE_URL}/attachments`;
|
||||||
|
|
||||||
export const attachmentsApiService = {
|
export const attachmentsApiService = {
|
||||||
getTaskAttachments: async (taskId: string): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
|
getTaskAttachments: async (
|
||||||
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(`${rootUrl}/tasks/${taskId}`);
|
taskId: string
|
||||||
|
): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
|
||||||
|
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(
|
||||||
|
`${rootUrl}/tasks/${taskId}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectAttachments: async (projectId: string, index: number, size: number): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
|
getProjectAttachments: async (
|
||||||
|
projectId: string,
|
||||||
|
index: number,
|
||||||
|
size: number
|
||||||
|
): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
|
||||||
const q = toQueryString({ index, size });
|
const q = toQueryString({ index, size });
|
||||||
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(`${rootUrl}/project/${projectId}${q}`);
|
const response = await apiClient.get<IServerResponse<IProjectAttachmentsViewModel>>(
|
||||||
|
`${rootUrl}/project/${projectId}${q}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadAttachment: async (id: string, filename: string): Promise<IServerResponse<string>> => {
|
downloadAttachment: async (id: string, filename: string): Promise<IServerResponse<string>> => {
|
||||||
const response = await apiClient.get<IServerResponse<string>>(`${rootUrl}/download?id=${id}&file=${filename}`);
|
const response = await apiClient.get<IServerResponse<string>>(
|
||||||
|
`${rootUrl}/download?id=${id}&file=${filename}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -27,7 +39,4 @@ export const attachmentsApiService = {
|
|||||||
const response = await apiClient.delete<IServerResponse<string>>(`${rootUrl}/tasks/${id}`);
|
const response = await apiClient.delete<IServerResponse<string>>(`${rootUrl}/tasks/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const projectMembersApiService = {
|
|||||||
createProjectMember: async (
|
createProjectMember: async (
|
||||||
body: IProjectMemberViewModel
|
body: IProjectMemberViewModel
|
||||||
): Promise<IServerResponse<IProjectMemberViewModel>> => {
|
): Promise<IServerResponse<IProjectMemberViewModel>> => {
|
||||||
const q = toQueryString({current_project_id: body.project_id});
|
const q = toQueryString({ current_project_id: body.project_id });
|
||||||
|
|
||||||
const response = await apiClient.post<IServerResponse<IProjectMemberViewModel>>(
|
const response = await apiClient.post<IServerResponse<IProjectMemberViewModel>>(
|
||||||
`${rootUrl}${q}`,
|
`${rootUrl}${q}`,
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export const projectTemplatesApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
createCustomTemplate: async (body: { template_id: string }): Promise<IServerResponse<IProjectTemplate>> => {
|
createCustomTemplate: async (body: {
|
||||||
|
template_id: string;
|
||||||
|
}): Promise<IServerResponse<IProjectTemplate>> => {
|
||||||
const response = await apiClient.post(`${rootUrl}/custom-template`, body);
|
const response = await apiClient.post(`${rootUrl}/custom-template`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -44,15 +46,17 @@ export const projectTemplatesApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
createFromWorklenzTemplate: async (body: { template_id: string }): Promise<IServerResponse<IProjectTemplate>> => {
|
createFromWorklenzTemplate: async (body: {
|
||||||
|
template_id: string;
|
||||||
|
}): Promise<IServerResponse<IProjectTemplate>> => {
|
||||||
const response = await apiClient.post(`${rootUrl}/import-template`, body);
|
const response = await apiClient.post(`${rootUrl}/import-template`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
createFromCustomTemplate: async (body: { template_id: string }): Promise<IServerResponse<IProjectTemplate>> => {
|
createFromCustomTemplate: async (body: {
|
||||||
|
template_id: string;
|
||||||
|
}): Promise<IServerResponse<IProjectTemplate>> => {
|
||||||
const response = await apiClient.post(`${rootUrl}/import-custom-template`, body);
|
const response = await apiClient.post(`${rootUrl}/import-custom-template`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ export const projectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProject: async (payload: UpdateProjectPayload): Promise<IServerResponse<IProjectViewModel>> => {
|
updateProject: async (
|
||||||
|
payload: UpdateProjectPayload
|
||||||
|
): Promise<IServerResponse<IProjectViewModel>> => {
|
||||||
const { id, ...data } = payload;
|
const { id, ...data } = payload;
|
||||||
const q = toQueryString({ current_project_id: id });
|
const q = toQueryString({ current_project_id: id });
|
||||||
const url = `${API_BASE_URL}/projects/${id}${q}`;
|
const url = `${API_BASE_URL}/projects/${id}${q}`;
|
||||||
@@ -127,7 +129,10 @@ export const projectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDefaultTab: async (body: { project_id: string; default_view: string }): Promise<IServerResponse<any>> => {
|
updateDefaultTab: async (body: {
|
||||||
|
project_id: string;
|
||||||
|
default_view: string;
|
||||||
|
}): Promise<IServerResponse<any>> => {
|
||||||
const url = `${rootUrl}/update-pinned-view`;
|
const url = `${rootUrl}/update-pinned-view`;
|
||||||
const response = await apiClient.put<IServerResponse<IProjectViewModel>>(`${url}`, body);
|
const response = await apiClient.put<IServerResponse<IProjectViewModel>>(`${url}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -139,4 +144,3 @@ export const projectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IGetProjectsRequestBody, IRPTMembersViewModel, IRPTOverviewProjectMember, IRPTProjectsViewModel } from '@/types/reporting/reporting.types';
|
import {
|
||||||
|
IGetProjectsRequestBody,
|
||||||
|
IRPTMembersViewModel,
|
||||||
|
IRPTOverviewProjectMember,
|
||||||
|
IRPTProjectsViewModel,
|
||||||
|
} from '@/types/reporting/reporting.types';
|
||||||
import apiClient from '../api-client';
|
import apiClient from '../api-client';
|
||||||
import { API_BASE_URL } from '@/shared/constants';
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import { toQueryString } from '@/utils/toQueryString';
|
import { toQueryString } from '@/utils/toQueryString';
|
||||||
@@ -7,9 +12,7 @@ import { toQueryString } from '@/utils/toQueryString';
|
|||||||
const rootUrl = `${API_BASE_URL}/reporting/members`;
|
const rootUrl = `${API_BASE_URL}/reporting/members`;
|
||||||
|
|
||||||
export const reportingMembersApiService = {
|
export const reportingMembersApiService = {
|
||||||
getMembers: async (
|
getMembers: async (body: any): Promise<IServerResponse<IRPTMembersViewModel>> => {
|
||||||
body: any
|
|
||||||
): Promise<IServerResponse<IRPTMembersViewModel>> => {
|
|
||||||
const q = toQueryString(body);
|
const q = toQueryString(body);
|
||||||
const url = `${rootUrl}${q}`;
|
const url = `${rootUrl}${q}`;
|
||||||
const response = await apiClient.get<IServerResponse<IRPTMembersViewModel>>(url);
|
const response = await apiClient.get<IServerResponse<IRPTMembersViewModel>>(url);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IGetProjectsRequestBody, IRPTOverviewProjectInfo, IRPTOverviewProjectMember, IRPTProjectsViewModel } from '@/types/reporting/reporting.types';
|
import {
|
||||||
|
IGetProjectsRequestBody,
|
||||||
|
IRPTOverviewProjectInfo,
|
||||||
|
IRPTOverviewProjectMember,
|
||||||
|
IRPTProjectsViewModel,
|
||||||
|
} from '@/types/reporting/reporting.types';
|
||||||
import apiClient from '../api-client';
|
import apiClient from '../api-client';
|
||||||
import { API_BASE_URL } from '@/shared/constants';
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import { toQueryString } from '@/utils/toQueryString';
|
import { toQueryString } from '@/utils/toQueryString';
|
||||||
@@ -33,8 +38,11 @@ export const reportingProjectsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTasks: async (projectId: string, groupBy: string): Promise<IServerResponse<ITaskListGroup[]>> => {
|
getTasks: async (
|
||||||
const q = toQueryString({group: groupBy})
|
projectId: string,
|
||||||
|
groupBy: string
|
||||||
|
): Promise<IServerResponse<ITaskListGroup[]>> => {
|
||||||
|
const q = toQueryString({ group: groupBy });
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/reporting/overview/project/tasks/${projectId}${q}`;
|
const url = `${API_BASE_URL}/reporting/overview/project/tasks/${projectId}${q}`;
|
||||||
const response = await apiClient.get<IServerResponse<ITaskListGroup[]>>(url);
|
const response = await apiClient.get<IServerResponse<ITaskListGroup[]>>(url);
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import { toQueryString } from '@/utils/toQueryString';
|
|||||||
import apiClient from '../api-client';
|
import apiClient from '../api-client';
|
||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
|
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
|
||||||
import { IProjectLogsBreakdown, IRPTTimeMember, IRPTTimeProject, ITimeLogBreakdownReq } from '@/types/reporting/reporting.types';
|
import {
|
||||||
|
IProjectLogsBreakdown,
|
||||||
|
IRPTTimeMember,
|
||||||
|
IRPTTimeProject,
|
||||||
|
ITimeLogBreakdownReq,
|
||||||
|
} from '@/types/reporting/reporting.types';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/reporting`;
|
const rootUrl = `${API_BASE_URL}/reporting`;
|
||||||
|
|
||||||
export const reportingTimesheetApiService = {
|
export const reportingTimesheetApiService = {
|
||||||
getTimeSheetData: async (body = {}, archived = false): Promise<IServerResponse<IAllocationViewModel>> => {
|
getTimeSheetData: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IAllocationViewModel>> => {
|
||||||
const q = toQueryString({ archived });
|
const q = toQueryString({ archived });
|
||||||
const response = await apiClient.post(`${rootUrl}/allocation/${q}`, body);
|
const response = await apiClient.post(`${rootUrl}/allocation/${q}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -19,24 +27,35 @@ export const reportingTimesheetApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
getProjectTimeSheets: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
||||||
const q = toQueryString({ archived });
|
const q = toQueryString({ archived });
|
||||||
const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, body);
|
const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getMemberTimeSheets: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeMember[]>> => {
|
getMemberTimeSheets: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IRPTTimeMember[]>> => {
|
||||||
const q = toQueryString({ archived });
|
const q = toQueryString({ archived });
|
||||||
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
|
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectTimeLogs: async (body: ITimeLogBreakdownReq): Promise<IServerResponse<IProjectLogsBreakdown[]>> => {
|
getProjectTimeLogs: async (
|
||||||
|
body: ITimeLogBreakdownReq
|
||||||
|
): Promise<IServerResponse<IProjectLogsBreakdown[]>> => {
|
||||||
const response = await apiClient.post(`${rootUrl}/project-timelogs`, body);
|
const response = await apiClient.post(`${rootUrl}/project-timelogs`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectEstimatedVsActual: async (body = {}, archived = false): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
getProjectEstimatedVsActual: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
||||||
const q = toQueryString({ archived });
|
const q = toQueryString({ archived });
|
||||||
const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, body);
|
const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { API_BASE_URL } from '@/shared/constants';
|
|||||||
import apiClient from '../api-client';
|
import apiClient from '../api-client';
|
||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
import { DateList, Member, Project, ScheduleData, Settings } from '@/types/schedule/schedule-v2.types';
|
import {
|
||||||
|
DateList,
|
||||||
|
Member,
|
||||||
|
Project,
|
||||||
|
ScheduleData,
|
||||||
|
Settings,
|
||||||
|
} from '@/types/schedule/schedule-v2.types';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/schedule-gannt-v2`;
|
const rootUrl = `${API_BASE_URL}/schedule-gannt-v2`;
|
||||||
|
|
||||||
@@ -45,16 +51,18 @@ export const scheduleAPIService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchMemberProjects: async ({ id }: { id: string }): Promise<IServerResponse<Project>> => {
|
fetchMemberProjects: async ({ id }: { id: string }): Promise<IServerResponse<Project>> => {
|
||||||
const response = await apiClient.get<IServerResponse<Project>>(`${rootUrl}/members/projects/${id}`);
|
const response = await apiClient.get<IServerResponse<Project>>(
|
||||||
|
`${rootUrl}/members/projects/${id}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
submitScheduleData: async ({
|
submitScheduleData: async ({
|
||||||
schedule
|
schedule,
|
||||||
}: {
|
}: {
|
||||||
schedule: ScheduleData
|
schedule: ScheduleData;
|
||||||
}): Promise<IServerResponse<any>> => {
|
}): Promise<IServerResponse<any>> => {
|
||||||
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/schedule`, schedule);
|
const response = await apiClient.post<IServerResponse<any>>(`${rootUrl}/schedule`, schedule);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export const profileSettingsApiService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateTeamName: async (id: string, body: ITeam): Promise<IServerResponse<ITeam>> => {
|
updateTeamName: async (id: string, body: ITeam): Promise<IServerResponse<ITeam>> => {
|
||||||
const response = await apiClient.put<IServerResponse<ITeam>>(`${rootUrl}/team-name/${id}`, body);
|
const response = await apiClient.put<IServerResponse<ITeam>>(
|
||||||
|
`${rootUrl}/team-name/${id}`,
|
||||||
|
body
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ export const taskTemplatesApiService = {
|
|||||||
const response = await apiClient.get<IServerResponse<ITaskTemplateGetResponse>>(`${url}`);
|
const response = await apiClient.get<IServerResponse<ITaskTemplateGetResponse>>(`${url}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
createTemplate: async (body: { name: string, tasks: IProjectTask[] }): Promise<IServerResponse<ITask>> => {
|
createTemplate: async (body: {
|
||||||
|
name: string;
|
||||||
|
tasks: IProjectTask[];
|
||||||
|
}): Promise<IServerResponse<ITask>> => {
|
||||||
const url = `${rootUrl}`;
|
const url = `${rootUrl}`;
|
||||||
const response = await apiClient.post<IServerResponse<ITask>>(`${url}`, body);
|
const response = await apiClient.post<IServerResponse<ITask>>(`${url}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
updateTemplate: async (id: string, body: { name: string, tasks: IProjectTask[] }): Promise<IServerResponse<ITask>> => {
|
updateTemplate: async (
|
||||||
|
id: string,
|
||||||
|
body: { name: string; tasks: IProjectTask[] }
|
||||||
|
): Promise<IServerResponse<ITask>> => {
|
||||||
const url = `${rootUrl}/${id}`;
|
const url = `${rootUrl}/${id}`;
|
||||||
const response = await apiClient.put<IServerResponse<ITask>>(`${url}`, body);
|
const response = await apiClient.put<IServerResponse<ITask>>(`${url}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const phasesApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNameOfPhase: async (phaseId: string, body: ITaskPhase, projectId: string,) => {
|
updateNameOfPhase: async (phaseId: string, body: ITaskPhase, projectId: string) => {
|
||||||
const q = toQueryString({ id: projectId, current_project_id: projectId });
|
const q = toQueryString({ id: projectId, current_project_id: projectId });
|
||||||
const response = await apiClient.put<IServerResponse<ITaskPhase>>(
|
const response = await apiClient.put<IServerResponse<ITaskPhase>>(
|
||||||
`${rootUrl}/${phaseId}${q}`,
|
`${rootUrl}/${phaseId}${q}`,
|
||||||
|
|||||||
@@ -69,8 +69,16 @@ export const statusApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteStatus: async (statusId: string, projectId: string, replacingStatusId: string): Promise<IServerResponse<void>> => {
|
deleteStatus: async (
|
||||||
const q = toQueryString({ project: projectId, current_project_id: projectId, replace: replacingStatusId || null });
|
statusId: string,
|
||||||
|
projectId: string,
|
||||||
|
replacingStatusId: string
|
||||||
|
): Promise<IServerResponse<void>> => {
|
||||||
|
const q = toQueryString({
|
||||||
|
project: projectId,
|
||||||
|
current_project_id: projectId,
|
||||||
|
replace: replacingStatusId || null,
|
||||||
|
});
|
||||||
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/${statusId}${q}`);
|
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/${statusId}${q}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL } from "@/shared/constants";
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import apiClient from "../api-client";
|
import apiClient from '../api-client';
|
||||||
import { IServerResponse } from "@/types/common.types";
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { ISubTask } from "@/types/tasks/subTask.types";
|
import { ISubTask } from '@/types/tasks/subTask.types';
|
||||||
|
|
||||||
const root = `${API_BASE_URL}/sub-tasks`;
|
const root = `${API_BASE_URL}/sub-tasks`;
|
||||||
|
|
||||||
@@ -10,7 +10,4 @@ export const subTasksApiService = {
|
|||||||
const response = await apiClient.get(`${root}/${parentTaskId}`);
|
const response = await apiClient.get(`${root}/${parentTaskId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { IProjectAttachmentsViewModel, ITaskAttachment, ITaskAttachmentViewModel } from '@/types/tasks/task-attachment-view-model';
|
import {
|
||||||
|
IProjectAttachmentsViewModel,
|
||||||
|
ITaskAttachment,
|
||||||
|
ITaskAttachmentViewModel,
|
||||||
|
} from '@/types/tasks/task-attachment-view-model';
|
||||||
import apiClient from '../api-client';
|
import apiClient from '../api-client';
|
||||||
import { API_BASE_URL } from '@/shared/constants';
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import { IAvatarAttachment } from '@/types/avatarAttachment.types';
|
import { IAvatarAttachment } from '@/types/avatarAttachment.types';
|
||||||
@@ -8,7 +12,6 @@ import { toQueryString } from '@/utils/toQueryString';
|
|||||||
const rootUrl = `${API_BASE_URL}/attachments`;
|
const rootUrl = `${API_BASE_URL}/attachments`;
|
||||||
|
|
||||||
const taskAttachmentsApiService = {
|
const taskAttachmentsApiService = {
|
||||||
|
|
||||||
createTaskAttachment: async (
|
createTaskAttachment: async (
|
||||||
body: ITaskAttachment
|
body: ITaskAttachment
|
||||||
): Promise<IServerResponse<ITaskAttachmentViewModel>> => {
|
): Promise<IServerResponse<ITaskAttachmentViewModel>> => {
|
||||||
@@ -16,17 +19,25 @@ const taskAttachmentsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
createAvatarAttachment: async (body: IAvatarAttachment): Promise<IServerResponse<{ url: string; }>> => {
|
createAvatarAttachment: async (
|
||||||
|
body: IAvatarAttachment
|
||||||
|
): Promise<IServerResponse<{ url: string }>> => {
|
||||||
const response = await apiClient.post(`${rootUrl}/avatar`, body);
|
const response = await apiClient.post(`${rootUrl}/avatar`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTaskAttachments: async (taskId: string): Promise<IServerResponse<ITaskAttachmentViewModel[]>> => {
|
getTaskAttachments: async (
|
||||||
|
taskId: string
|
||||||
|
): Promise<IServerResponse<ITaskAttachmentViewModel[]>> => {
|
||||||
const response = await apiClient.get(`${rootUrl}/tasks/${taskId}`);
|
const response = await apiClient.get(`${rootUrl}/tasks/${taskId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getProjectAttachments: async (projectId: string, index: number, size: number ): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
|
getProjectAttachments: async (
|
||||||
|
projectId: string,
|
||||||
|
index: number,
|
||||||
|
size: number
|
||||||
|
): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
|
||||||
const q = toQueryString({ index, size });
|
const q = toQueryString({ index, size });
|
||||||
const response = await apiClient.get(`${rootUrl}/project/${projectId}${q}`);
|
const response = await apiClient.get(`${rootUrl}/project/${projectId}${q}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import apiClient from '@api/api-client';
|
|||||||
import { API_BASE_URL } from '@/shared/constants';
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import { IServerResponse } from '@/types/common.types';
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { toQueryString } from '@/utils/toQueryString';
|
import { toQueryString } from '@/utils/toQueryString';
|
||||||
import { ITaskComment, ITaskCommentsCreateRequest, ITaskCommentViewModel } from '@/types/tasks/task-comments.types';
|
import {
|
||||||
|
ITaskComment,
|
||||||
|
ITaskCommentsCreateRequest,
|
||||||
|
ITaskCommentViewModel,
|
||||||
|
} from '@/types/tasks/task-comments.types';
|
||||||
|
|
||||||
const taskCommentsApiService = {
|
const taskCommentsApiService = {
|
||||||
create: async (data: ITaskCommentsCreateRequest): Promise<IServerResponse<ITaskCommentsCreateRequest>> => {
|
create: async (
|
||||||
|
data: ITaskCommentsCreateRequest
|
||||||
|
): Promise<IServerResponse<ITaskCommentsCreateRequest>> => {
|
||||||
const response = await apiClient.post(`${API_BASE_URL}/task-comments`, data);
|
const response = await apiClient.post(`${API_BASE_URL}/task-comments`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -21,12 +27,16 @@ const taskCommentsApiService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteAttachment: async (id: string, taskId: string): Promise<IServerResponse<ITaskComment>> => {
|
deleteAttachment: async (id: string, taskId: string): Promise<IServerResponse<ITaskComment>> => {
|
||||||
const response = await apiClient.delete(`${API_BASE_URL}/task-comments/attachment/${id}/${taskId}`);
|
const response = await apiClient.delete(
|
||||||
|
`${API_BASE_URL}/task-comments/attachment/${id}/${taskId}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
download: async (id: string, filename: string): Promise<IServerResponse<any>> => {
|
download: async (id: string, filename: string): Promise<IServerResponse<any>> => {
|
||||||
const response = await apiClient.get(`${API_BASE_URL}/task-comments/download?id=${id}&file=${filename}`);
|
const response = await apiClient.get(
|
||||||
|
`${API_BASE_URL}/task-comments/download?id=${id}&file=${filename}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -35,8 +45,13 @@ const taskCommentsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateReaction: async (id: string, body: {reaction_type: string, task_id: string}): Promise<IServerResponse<ITaskComment>> => {
|
updateReaction: async (
|
||||||
const response = await apiClient.put(`${API_BASE_URL}/task-comments/reaction/${id}${toQueryString(body)}`);
|
id: string,
|
||||||
|
body: { reaction_type: string; task_id: string }
|
||||||
|
): Promise<IServerResponse<ITaskComment>> => {
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`${API_BASE_URL}/task-comments/reaction/${id}${toQueryString(body)}`
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL } from "@/shared/constants";
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import apiClient from "../api-client";
|
import apiClient from '../api-client';
|
||||||
import { ITaskDependency } from "@/types/tasks/task-dependency.types";
|
import { ITaskDependency } from '@/types/tasks/task-dependency.types';
|
||||||
import { IServerResponse } from "@/types/common.types";
|
import { IServerResponse } from '@/types/common.types';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/task-dependencies`;
|
const rootUrl = `${API_BASE_URL}/task-dependencies`;
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ export const taskDependenciesApiService = {
|
|||||||
const response = await apiClient.get(`${rootUrl}/${taskId}`);
|
const response = await apiClient.get(`${rootUrl}/${taskId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
createTaskDependency: async (body: ITaskDependency): Promise<IServerResponse<ITaskDependency>> => {
|
createTaskDependency: async (
|
||||||
|
body: ITaskDependency
|
||||||
|
): Promise<IServerResponse<ITaskDependency>> => {
|
||||||
const response = await apiClient.post(`${rootUrl}`, body);
|
const response = await apiClient.post(`${rootUrl}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { API_BASE_URL } from "@/shared/constants";
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import { IServerResponse } from "@/types/common.types";
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule";
|
import { ITaskRecurringSchedule } from '@/types/tasks/task-recurring-schedule';
|
||||||
import apiClient from "../api-client";
|
import apiClient from '../api-client';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/task-recurring`;
|
const rootUrl = `${API_BASE_URL}/task-recurring`;
|
||||||
|
|
||||||
export const taskRecurringApiService = {
|
export const taskRecurringApiService = {
|
||||||
getTaskRecurringData: async (schedule_id: string): Promise<IServerResponse<ITaskRecurringSchedule>> => {
|
getTaskRecurringData: async (
|
||||||
const response = await apiClient.get(`${rootUrl}/${schedule_id}`);
|
schedule_id: string
|
||||||
return response.data;
|
): Promise<IServerResponse<ITaskRecurringSchedule>> => {
|
||||||
},
|
const response = await apiClient.get(`${rootUrl}/${schedule_id}`);
|
||||||
updateTaskRecurringData: async (schedule_id: string, body: any): Promise<IServerResponse<ITaskRecurringSchedule>> => {
|
return response.data;
|
||||||
return apiClient.put(`${rootUrl}/${schedule_id}`, body);
|
},
|
||||||
}
|
updateTaskRecurringData: async (
|
||||||
}
|
schedule_id: string,
|
||||||
|
body: any
|
||||||
|
): Promise<IServerResponse<ITaskRecurringSchedule>> => {
|
||||||
|
return apiClient.put(`${rootUrl}/${schedule_id}`, body);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { API_BASE_URL } from "@/shared/constants";
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
import apiClient from "../api-client";
|
import apiClient from '../api-client';
|
||||||
import { IServerResponse } from "@/types/common.types";
|
import { IServerResponse } from '@/types/common.types';
|
||||||
import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types";
|
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
const rootUrl = `${API_BASE_URL}/task-time-log`;
|
||||||
|
|
||||||
@@ -16,12 +16,12 @@ export interface IRunningTimer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const taskTimeLogsApiService = {
|
export const taskTimeLogsApiService = {
|
||||||
getByTask: async (id: string) : Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
getByTask: async (id: string): Promise<IServerResponse<ITaskLogViewModel[]>> => {
|
||||||
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
const response = await apiClient.get(`${rootUrl}/task/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string, taskId: string) : Promise<IServerResponse<void>> => {
|
delete: async (id: string, taskId: string): Promise<IServerResponse<void>> => {
|
||||||
const response = await apiClient.delete(`${rootUrl}/${id}?task=${taskId}`);
|
const response = await apiClient.delete(`${rootUrl}/${id}?task=${taskId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ITaskListColumn } from "@/types/tasks/taskList.types";
|
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||||
import apiClient from "../api-client";
|
import apiClient from '../api-client';
|
||||||
import { IServerResponse } from "@/types/common.types";
|
import { IServerResponse } from '@/types/common.types';
|
||||||
|
|
||||||
export const tasksCustomColumnsService = {
|
export const tasksCustomColumnsService = {
|
||||||
getCustomColumns: async (projectId: string): Promise<IServerResponse<ITaskListColumn[]>> => {
|
getCustomColumns: async (projectId: string): Promise<IServerResponse<ITaskListColumn[]>> => {
|
||||||
@@ -17,7 +17,7 @@ export const tasksCustomColumnsService = {
|
|||||||
const response = await apiClient.put(`/api/v1/tasks/${taskId}/custom-column`, {
|
const response = await apiClient.put(`/api/v1/tasks/${taskId}/custom-column`, {
|
||||||
column_key: columnKey,
|
column_key: columnKey,
|
||||||
value: value,
|
value: value,
|
||||||
project_id: projectId
|
project_id: projectId,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -35,7 +35,7 @@ export const tasksCustomColumnsService = {
|
|||||||
): Promise<IServerResponse<any>> => {
|
): Promise<IServerResponse<any>> => {
|
||||||
const response = await apiClient.post('/api/v1/custom-columns', {
|
const response = await apiClient.post('/api/v1/custom-columns', {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
...columnData
|
...columnData,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -63,7 +63,10 @@ export const tasksCustomColumnsService = {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
item: ITaskListColumn
|
item: ITaskListColumn
|
||||||
): Promise<IServerResponse<ITaskListColumn>> => {
|
): Promise<IServerResponse<ITaskListColumn>> => {
|
||||||
const response = await apiClient.put(`/api/v1/custom-columns/project/${projectId}/columns`, item);
|
const response = await apiClient.put(
|
||||||
|
`/api/v1/custom-columns/project/${projectId}/columns`,
|
||||||
|
item
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -131,14 +131,19 @@ export const tasksApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTaskDependencyStatus: async (taskId: string, statusId: string): Promise<IServerResponse<{ can_continue: boolean }>> => {
|
getTaskDependencyStatus: async (
|
||||||
const q = toQueryString({taskId, statusId});
|
taskId: string,
|
||||||
|
statusId: string
|
||||||
|
): Promise<IServerResponse<{ can_continue: boolean }>> => {
|
||||||
|
const q = toQueryString({ taskId, statusId });
|
||||||
const response = await apiClient.get(`${rootUrl}/dependency-status${q}`);
|
const response = await apiClient.get(`${rootUrl}/dependency-status${q}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTaskListV3: async (config: ITaskListConfigV2): Promise<IServerResponse<ITaskListV3Response>> => {
|
getTaskListV3: async (
|
||||||
const q = toQueryString({ ...config, include_empty: "true" });
|
config: ITaskListConfigV2
|
||||||
|
): Promise<IServerResponse<ITaskListV3Response>> => {
|
||||||
|
const q = toQueryString({ ...config, include_empty: 'true' });
|
||||||
const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`);
|
const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -148,20 +153,28 @@ export const tasksApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTaskProgressStatus: async (projectId: string): Promise<IServerResponse<{
|
getTaskProgressStatus: async (
|
||||||
projectId: string;
|
projectId: string
|
||||||
totalTasks: number;
|
): Promise<
|
||||||
completedTasks: number;
|
IServerResponse<{
|
||||||
avgProgress: number;
|
projectId: string;
|
||||||
lastUpdated: string;
|
totalTasks: number;
|
||||||
completionPercentage: number;
|
completedTasks: number;
|
||||||
}>> => {
|
avgProgress: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
completionPercentage: number;
|
||||||
|
}>
|
||||||
|
> => {
|
||||||
const response = await apiClient.get(`${rootUrl}/progress-status/${projectId}`);
|
const response = await apiClient.get(`${rootUrl}/progress-status/${projectId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// API method to reorder tasks
|
// API method to reorder tasks
|
||||||
reorderTasks: async (params: { taskIds: string[]; newOrder: number[]; projectId: string }): Promise<IServerResponse<{ done: boolean }>> => {
|
reorderTasks: async (params: {
|
||||||
|
taskIds: string[];
|
||||||
|
newOrder: number[];
|
||||||
|
projectId: string;
|
||||||
|
}): Promise<IServerResponse<{ done: boolean }>> => {
|
||||||
const response = await apiClient.post(`${rootUrl}/reorder`, {
|
const response = await apiClient.post(`${rootUrl}/reorder`, {
|
||||||
task_ids: params.taskIds,
|
task_ids: params.taskIds,
|
||||||
new_order: params.newOrder,
|
new_order: params.newOrder,
|
||||||
@@ -171,7 +184,12 @@ export const tasksApiService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// API method to update task group (status, priority, phase)
|
// API method to update task group (status, priority, phase)
|
||||||
updateTaskGroup: async (params: { taskId: string; groupType: 'status' | 'priority' | 'phase'; groupValue: string; projectId: string }): Promise<IServerResponse<{ done: boolean }>> => {
|
updateTaskGroup: async (params: {
|
||||||
|
taskId: string;
|
||||||
|
groupType: 'status' | 'priority' | 'phase';
|
||||||
|
groupValue: string;
|
||||||
|
projectId: string;
|
||||||
|
}): Promise<IServerResponse<{ done: boolean }>> => {
|
||||||
const response = await apiClient.put(`${rootUrl}/${params.taskId}/group`, {
|
const response = await apiClient.put(`${rootUrl}/${params.taskId}/group`, {
|
||||||
group_type: params.groupType,
|
group_type: params.groupType,
|
||||||
group_value: params.groupValue,
|
group_value: params.groupValue,
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export const teamMembersApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getAll: async (projectId: string | null = null): Promise<IServerResponse<ITeamMemberViewModel[]>> => {
|
getAll: async (
|
||||||
|
projectId: string | null = null
|
||||||
|
): Promise<IServerResponse<ITeamMemberViewModel[]>> => {
|
||||||
const params = new URLSearchParams(projectId ? { project: projectId } : {});
|
const params = new URLSearchParams(projectId ? { project: projectId } : {});
|
||||||
const response = await apiClient.get<IServerResponse<ITeamMemberViewModel[]>>(
|
const response = await apiClient.get<IServerResponse<ITeamMemberViewModel[]>>(
|
||||||
`${rootUrl}/all${params.toString() ? '?' + params.toString() : ''}`
|
`${rootUrl}/all${params.toString() ? '?' + params.toString() : ''}`
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ const rootUrl = `${API_BASE_URL}/teams`;
|
|||||||
|
|
||||||
export const teamsApiService = {
|
export const teamsApiService = {
|
||||||
getTeams: async (): Promise<IServerResponse<ITeamGetResponse[]>> => {
|
getTeams: async (): Promise<IServerResponse<ITeamGetResponse[]>> => {
|
||||||
const response = await apiClient.get<IServerResponse<ITeamGetResponse[]>>(
|
const response = await apiClient.get<IServerResponse<ITeamGetResponse[]>>(`${rootUrl}`);
|
||||||
`${rootUrl}`
|
|
||||||
);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setActiveTeam: async (teamId: string): Promise<IServerResponse<ITeamActivateResponse>> => {
|
setActiveTeam: async (teamId: string): Promise<IServerResponse<ITeamActivateResponse>> => {
|
||||||
@@ -29,23 +26,18 @@ export const teamsApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
createTeam: async (team: IOrganizationTeam): Promise<IServerResponse<ITeam>> => {
|
createTeam: async (team: IOrganizationTeam): Promise<IServerResponse<ITeam>> => {
|
||||||
const response = await apiClient.post<IServerResponse<ITeam>>(`${rootUrl}`, team);
|
const response = await apiClient.post<IServerResponse<ITeam>>(`${rootUrl}`, team);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
getInvitations: async (): Promise<IServerResponse<ITeamInvites[]>> => {
|
getInvitations: async (): Promise<IServerResponse<ITeamInvites[]>> => {
|
||||||
const response = await apiClient.get<IServerResponse<ITeamInvites[]>>(
|
const response = await apiClient.get<IServerResponse<ITeamInvites[]>>(`${rootUrl}/invites`);
|
||||||
`${rootUrl}/invites`
|
|
||||||
);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
acceptInvitation: async (body: IAcceptTeamInvite): Promise<IServerResponse<ITeamInvites>> => {
|
acceptInvitation: async (body: IAcceptTeamInvite): Promise<IServerResponse<ITeamInvites>> => {
|
||||||
const response = await apiClient.put<IServerResponse<ITeamInvites>>(`${rootUrl}`, body);
|
const response = await apiClient.put<IServerResponse<ITeamInvites>>(`${rootUrl}`, body);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class ReduxPerformanceMonitor {
|
|||||||
export const performanceMonitor = new ReduxPerformanceMonitor();
|
export const performanceMonitor = new ReduxPerformanceMonitor();
|
||||||
|
|
||||||
// Redux middleware for performance monitoring
|
// Redux middleware for performance monitoring
|
||||||
export const performanceMiddleware: Middleware = (store) => (next) => (action: any) => {
|
export const performanceMiddleware: Middleware = store => next => (action: any) => {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
const result = next(action);
|
const result = next(action);
|
||||||
@@ -110,7 +110,8 @@ export function analyzeReduxPerformance() {
|
|||||||
analysis.recommendations.push('Consider optimizing selectors with createSelector');
|
analysis.recommendations.push('Consider optimizing selectors with createSelector');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (analysis.largestStateSize > 1000000) { // 1MB
|
if (analysis.largestStateSize > 1000000) {
|
||||||
|
// 1MB
|
||||||
analysis.recommendations.push('State size is large - consider normalizing data');
|
analysis.recommendations.push('State size is large - consider normalizing data');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,12 @@ export const AdminGuard = memo(({ children }: GuardProps) => {
|
|||||||
const guardResult = useMemo(() => {
|
const guardResult = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
// Defensive checks to ensure authService and its methods exist
|
// Defensive checks to ensure authService and its methods exist
|
||||||
if (!authService ||
|
if (
|
||||||
typeof authService.isAuthenticated !== 'function' ||
|
!authService ||
|
||||||
typeof authService.isOwnerOrAdmin !== 'function' ||
|
typeof authService.isAuthenticated !== 'function' ||
|
||||||
typeof authService.getCurrentSession !== 'function') {
|
typeof authService.isOwnerOrAdmin !== 'function' ||
|
||||||
|
typeof authService.getCurrentSession !== 'function'
|
||||||
|
) {
|
||||||
return null; // Don't redirect if auth service is not ready
|
return null; // Don't redirect if auth service is not ready
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +105,11 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
|
|||||||
const shouldRedirect = useMemo(() => {
|
const shouldRedirect = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
// Defensive checks to ensure authService and its methods exist
|
// Defensive checks to ensure authService and its methods exist
|
||||||
if (!authService ||
|
if (
|
||||||
typeof authService.isAuthenticated !== 'function' ||
|
!authService ||
|
||||||
typeof authService.getCurrentSession !== 'function') {
|
typeof authService.isAuthenticated !== 'function' ||
|
||||||
|
typeof authService.getCurrentSession !== 'function'
|
||||||
|
) {
|
||||||
return false; // Don't redirect if auth service is not ready
|
return false; // Don't redirect if auth service is not ready
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +144,10 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If not marked as expired but has trial_expire_date, do a date check
|
// If not marked as expired but has trial_expire_date, do a date check
|
||||||
if (currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && currentSession.trial_expire_date) {
|
if (
|
||||||
|
currentSession.subscription_type === ISUBSCRIPTION_TYPE.TRIAL &&
|
||||||
|
currentSession.trial_expire_date
|
||||||
|
) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const expiryDate = new Date(currentSession.trial_expire_date);
|
const expiryDate = new Date(currentSession.trial_expire_date);
|
||||||
|
|
||||||
@@ -227,23 +234,27 @@ const wrapRoutes = (
|
|||||||
// Optimized static license expired component
|
// Optimized static license expired component
|
||||||
const StaticLicenseExpired = memo(() => {
|
const StaticLicenseExpired = memo(() => {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
marginTop: 65,
|
style={{
|
||||||
minHeight: '90vh',
|
marginTop: 65,
|
||||||
backgroundColor: '#f5f5f5',
|
minHeight: '90vh',
|
||||||
padding: '20px',
|
backgroundColor: '#f5f5f5',
|
||||||
display: 'flex',
|
padding: '20px',
|
||||||
justifyContent: 'center',
|
display: 'flex',
|
||||||
alignItems: 'center'
|
justifyContent: 'center',
|
||||||
}}>
|
alignItems: 'center',
|
||||||
<div style={{
|
}}
|
||||||
background: 'white',
|
>
|
||||||
padding: '30px',
|
<div
|
||||||
borderRadius: '8px',
|
style={{
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
background: 'white',
|
||||||
textAlign: 'center',
|
padding: '30px',
|
||||||
maxWidth: '600px'
|
borderRadius: '8px',
|
||||||
}}>
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '600px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<h1 style={{ fontSize: '24px', color: '#faad14', marginBottom: '16px' }}>
|
<h1 style={{ fontSize: '24px', color: '#faad14', marginBottom: '16px' }}>
|
||||||
Your Worklenz trial has expired!
|
Your Worklenz trial has expired!
|
||||||
</h1>
|
</h1>
|
||||||
@@ -258,9 +269,9 @@ const StaticLicenseExpired = memo(() => {
|
|||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
onClick={() => window.location.href = '/worklenz/admin-center/billing'}
|
onClick={() => (window.location.href = '/worklenz/admin-center/billing')}
|
||||||
>
|
>
|
||||||
Upgrade now
|
Upgrade now
|
||||||
</button>
|
</button>
|
||||||
@@ -272,11 +283,7 @@ const StaticLicenseExpired = memo(() => {
|
|||||||
StaticLicenseExpired.displayName = 'StaticLicenseExpired';
|
StaticLicenseExpired.displayName = 'StaticLicenseExpired';
|
||||||
|
|
||||||
// Create route arrays (moved outside of useMemo to avoid hook violations)
|
// Create route arrays (moved outside of useMemo to avoid hook violations)
|
||||||
const publicRoutes = [
|
const publicRoutes = [...rootRoutes, ...authRoutes, notFoundRoute];
|
||||||
...rootRoutes,
|
|
||||||
...authRoutes,
|
|
||||||
notFoundRoute
|
|
||||||
];
|
|
||||||
|
|
||||||
const protectedMainRoutes = wrapRoutes(mainRoutes, AuthGuard);
|
const protectedMainRoutes = wrapRoutes(mainRoutes, AuthGuard);
|
||||||
const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard);
|
const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard);
|
||||||
@@ -305,37 +312,35 @@ const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => {
|
|||||||
const licenseCheckedMainRoutes = withLicenseExpiryCheck(protectedMainRoutes);
|
const licenseCheckedMainRoutes = withLicenseExpiryCheck(protectedMainRoutes);
|
||||||
|
|
||||||
// Create optimized router with future flags for better performance
|
// Create optimized router with future flags for better performance
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
element: (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AuthenticatedLayout />
|
||||||
|
</ErrorBoundary>
|
||||||
|
),
|
||||||
|
errorElement: (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
|
<NotFoundPage />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
),
|
||||||
|
children: [...licenseCheckedMainRoutes, ...adminRoutes, ...setupRoutes, licenseExpiredRoute],
|
||||||
|
},
|
||||||
|
...publicRoutes,
|
||||||
|
],
|
||||||
{
|
{
|
||||||
element: (
|
// Enable React Router future features for better performance
|
||||||
<ErrorBoundary>
|
future: {
|
||||||
<AuthenticatedLayout />
|
v7_relativeSplatPath: true,
|
||||||
</ErrorBoundary>
|
v7_fetcherPersist: true,
|
||||||
),
|
v7_normalizeFormMethod: true,
|
||||||
errorElement: (
|
v7_partialHydration: true,
|
||||||
<ErrorBoundary>
|
v7_skipActionErrorRevalidation: true,
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
},
|
||||||
<NotFoundPage />
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
...licenseCheckedMainRoutes,
|
|
||||||
...adminRoutes,
|
|
||||||
...setupRoutes,
|
|
||||||
licenseExpiredRoute,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
...publicRoutes,
|
|
||||||
], {
|
|
||||||
// Enable React Router future features for better performance
|
|
||||||
future: {
|
|
||||||
v7_relativeSplatPath: true,
|
|
||||||
v7_fetcherPersist: true,
|
|
||||||
v7_normalizeFormMethod: true,
|
|
||||||
v7_partialHydration: true,
|
|
||||||
v7_skipActionErrorRevalidation: true
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba
|
|||||||
const HomePage = lazy(() => import('@/pages/home/home-page'));
|
const HomePage = lazy(() => import('@/pages/home/home-page'));
|
||||||
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
|
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
|
||||||
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
|
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
|
||||||
const ProjectTemplateEditView = lazy(() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView'));
|
const ProjectTemplateEditView = lazy(
|
||||||
|
() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')
|
||||||
|
);
|
||||||
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
|
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
|
||||||
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
|
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
|
||||||
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
|
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
|
||||||
@@ -23,9 +25,11 @@ const AdminGuard = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Defensive checks to ensure authService and its methods exist
|
// Defensive checks to ensure authService and its methods exist
|
||||||
if (!authService ||
|
if (
|
||||||
typeof authService.isAuthenticated !== 'function' ||
|
!authService ||
|
||||||
typeof authService.isOwnerOrAdmin !== 'function') {
|
typeof authService.isAuthenticated !== 'function' ||
|
||||||
|
typeof authService.isOwnerOrAdmin !== 'function'
|
||||||
|
) {
|
||||||
// If auth service is not ready, render children (don't block)
|
// If auth service is not ready, render children (don't block)
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@@ -58,7 +62,7 @@ const mainRoutes: RouteObject[] = [
|
|||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'projects',
|
path: 'projects',
|
||||||
@@ -66,7 +70,7 @@ const mainRoutes: RouteObject[] = [
|
|||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<ProjectList />
|
<ProjectList />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'schedule',
|
path: 'schedule',
|
||||||
@@ -76,7 +80,7 @@ const mainRoutes: RouteObject[] = [
|
|||||||
<Schedule />
|
<Schedule />
|
||||||
</AdminGuard>
|
</AdminGuard>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `projects/:projectId`,
|
path: `projects/:projectId`,
|
||||||
@@ -84,7 +88,7 @@ const mainRoutes: RouteObject[] = [
|
|||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<ProjectView />
|
<ProjectView />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `settings/project-templates/edit/:templateId/:templateName`,
|
path: `settings/project-templates/edit/:templateId/:templateName`,
|
||||||
@@ -100,7 +104,7 @@ const mainRoutes: RouteObject[] = [
|
|||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<Unauthorized />
|
<Unauthorized />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
...settingsRoutes,
|
...settingsRoutes,
|
||||||
...adminCenterRoutes,
|
...adminCenterRoutes,
|
||||||
@@ -119,9 +123,9 @@ export const licenseExpiredRoute: RouteObject = {
|
|||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
<LicenseExpired />
|
<LicenseExpired />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mainRoutes;
|
export default mainRoutes;
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import SettingsLayout from '@/layouts/SettingsLayout';
|
|||||||
import { settingsItems } from '@/lib/settings/settings-constants';
|
import { settingsItems } from '@/lib/settings/settings-constants';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
|
||||||
const SettingsGuard = ({ children, adminRequired }: { children: React.ReactNode; adminRequired: boolean }) => {
|
const SettingsGuard = ({
|
||||||
|
children,
|
||||||
|
adminRequired,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
adminRequired: boolean;
|
||||||
|
}) => {
|
||||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
|
||||||
if (adminRequired && !isOwnerOrAdmin) {
|
if (adminRequired && !isOwnerOrAdmin) {
|
||||||
@@ -20,11 +26,7 @@ const settingsRoutes: RouteObject[] = [
|
|||||||
element: <SettingsLayout />,
|
element: <SettingsLayout />,
|
||||||
children: settingsItems.map(item => ({
|
children: settingsItems.map(item => ({
|
||||||
path: item.endpoint,
|
path: item.endpoint,
|
||||||
element: (
|
element: <SettingsGuard adminRequired={!!item.adminOnly}>{item.element}</SettingsGuard>,
|
||||||
<SettingsGuard adminRequired={!!item.adminOnly}>
|
|
||||||
{item.element}
|
|
||||||
</SettingsGuard>
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import { RootState } from './store';
|
|||||||
// Auth selectors
|
// Auth selectors
|
||||||
export const selectAuth = (state: RootState) => state.auth;
|
export const selectAuth = (state: RootState) => state.auth;
|
||||||
export const selectUser = (state: RootState) => state.userReducer;
|
export const selectUser = (state: RootState) => state.userReducer;
|
||||||
export const selectIsAuthenticated = createSelector(
|
export const selectIsAuthenticated = createSelector([selectAuth], auth => !!auth.user);
|
||||||
[selectAuth],
|
|
||||||
(auth) => !!auth.user
|
|
||||||
);
|
|
||||||
|
|
||||||
// Project selectors
|
// Project selectors
|
||||||
export const selectProjects = (state: RootState) => state.projectsReducer;
|
export const selectProjects = (state: RootState) => state.projectsReducer;
|
||||||
@@ -69,13 +66,10 @@ export const selectGroupByFilter = (state: RootState) => state.groupByFilterDrop
|
|||||||
// Memoized computed selectors for common use cases
|
// Memoized computed selectors for common use cases
|
||||||
export const selectHasActiveProject = createSelector(
|
export const selectHasActiveProject = createSelector(
|
||||||
[selectCurrentProject],
|
[selectCurrentProject],
|
||||||
(project) => !!project && Object.keys(project).length > 0
|
project => !!project && Object.keys(project).length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectIsLoading = createSelector(
|
export const selectIsLoading = createSelector([selectTasks, selectProjects], (tasks, projects) => {
|
||||||
[selectTasks, selectProjects],
|
// Check if any major feature is loading
|
||||||
(tasks, projects) => {
|
return (tasks as any)?.loading || (projects as any)?.loading;
|
||||||
// Check if any major feature is loading
|
});
|
||||||
return (tasks as any)?.loading || (projects as any)?.loading;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface AssigneeSelectorProps {
|
|||||||
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||||
task,
|
task,
|
||||||
groupId = null,
|
groupId = null,
|
||||||
isDarkMode = false
|
isDarkMode = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -63,8 +63,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
// Close dropdown when clicking outside and handle scroll
|
// Close dropdown when clicking outside and handle scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
if (
|
||||||
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -74,9 +78,11 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
// Check if the button is still visible in the viewport
|
// Check if the button is still visible in the viewport
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
const isVisible = rect.top >= 0 && rect.left >= 0 &&
|
const isVisible =
|
||||||
rect.bottom <= window.innerHeight &&
|
rect.top >= 0 &&
|
||||||
rect.right <= window.innerWidth;
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= window.innerHeight &&
|
||||||
|
rect.right <= window.innerWidth;
|
||||||
|
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
updateDropdownPosition();
|
updateDropdownPosition();
|
||||||
@@ -161,10 +167,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
setTeamMembers(prev => ({
|
setTeamMembers(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
data: (prev.data || []).map(member =>
|
data: (prev.data || []).map(member =>
|
||||||
member.id === memberId
|
member.id === memberId ? { ...member, selected: checked } : member
|
||||||
? { ...member, selected: checked }
|
),
|
||||||
: member
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
@@ -178,12 +182,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
|
|
||||||
// Emit socket event - the socket handler will update Redux with proper types
|
// Emit socket event - the socket handler will update Redux with proper types
|
||||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||||
socket?.once(
|
socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => {
|
||||||
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
|
dispatch(updateEnhancedKanbanTaskAssignees(data));
|
||||||
(data: any) => {
|
});
|
||||||
dispatch(updateEnhancedKanbanTaskAssignees(data));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove from pending changes after a short delay (optimistic)
|
// Remove from pending changes after a short delay (optimistic)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -198,9 +199,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
const checkMemberSelected = (memberId: string) => {
|
const checkMemberSelected = (memberId: string) => {
|
||||||
if (!memberId) return false;
|
if (!memberId) return false;
|
||||||
// Use optimistic assignees if available, otherwise fall back to task assignees
|
// Use optimistic assignees if available, otherwise fall back to task assignees
|
||||||
const assignees = optimisticAssignees.length > 0
|
const assignees =
|
||||||
? optimisticAssignees
|
optimisticAssignees.length > 0
|
||||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
? optimisticAssignees
|
||||||
|
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
||||||
return assignees.includes(memberId);
|
return assignees.includes(memberId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,147 +219,157 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
className={`
|
className={`
|
||||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
${isOpen
|
${
|
||||||
? isDarkMode
|
isOpen
|
||||||
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
? isDarkMode
|
||||||
: 'border-blue-500 bg-blue-50 text-blue-600'
|
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||||
: isDarkMode
|
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
: isDarkMode
|
||||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<PlusOutlined className="text-xs" />
|
<PlusOutlined className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && createPortal(
|
{isOpen &&
|
||||||
<div
|
createPortal(
|
||||||
ref={dropdownRef}
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
ref={dropdownRef}
|
||||||
className={`
|
onClick={e => e.stopPropagation()}
|
||||||
|
className={`
|
||||||
fixed z-9999 w-72 rounded-md shadow-lg border
|
fixed z-9999 w-72 rounded-md shadow-lg border
|
||||||
${isDarkMode
|
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
|
||||||
? 'bg-gray-800 border-gray-600'
|
|
||||||
: 'bg-white border-gray-200'
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
top: dropdownPosition.top,
|
top: dropdownPosition.top,
|
||||||
left: dropdownPosition.left,
|
left: dropdownPosition.left,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search members..."
|
placeholder="Search members..."
|
||||||
className={`
|
className={`
|
||||||
w-full px-2 py-1 text-xs rounded border
|
w-full px-2 py-1 text-xs rounded border
|
||||||
${isDarkMode
|
${
|
||||||
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
isDarkMode
|
||||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||||
|
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||||
}
|
}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500
|
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members List */}
|
{/* Members List */}
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
{filteredMembers && filteredMembers.length > 0 ? (
|
{filteredMembers && filteredMembers.length > 0 ? (
|
||||||
filteredMembers.map((member) => (
|
filteredMembers.map(member => (
|
||||||
<div
|
<div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||||
${member.pending_invitation
|
${
|
||||||
? 'opacity-50 cursor-not-allowed'
|
member.pending_invitation
|
||||||
: isDarkMode
|
? 'opacity-50 cursor-not-allowed'
|
||||||
? 'hover:bg-gray-700'
|
: isDarkMode
|
||||||
: 'hover:bg-gray-50'
|
? 'hover:bg-gray-700'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!member.pending_invitation) {
|
if (!member.pending_invitation) {
|
||||||
const isSelected = checkMemberSelected(member.id || '');
|
const isSelected = checkMemberSelected(member.id || '');
|
||||||
handleMemberToggle(member.id || '', !isSelected);
|
handleMemberToggle(member.id || '', !isSelected);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
// Add visual feedback for immediate response
|
// Add visual feedback for immediate response
|
||||||
transition: 'all 0.15s ease-in-out',
|
transition: 'all 0.15s ease-in-out',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span onClick={e => e.stopPropagation()}>
|
<span onClick={e => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={checkMemberSelected(member.id || '')}
|
checked={checkMemberSelected(member.id || '')}
|
||||||
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
onChange={checked => handleMemberToggle(member.id || '', checked)}
|
||||||
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
|
disabled={
|
||||||
isDarkMode={isDarkMode}
|
member.pending_invitation || pendingChanges.has(member.id || '')
|
||||||
/>
|
}
|
||||||
</span>
|
isDarkMode={isDarkMode}
|
||||||
{pendingChanges.has(member.id || '') && (
|
/>
|
||||||
<div className={`absolute inset-0 flex items-center justify-center ${
|
</span>
|
||||||
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
{pendingChanges.has(member.id || '') && (
|
||||||
}`}>
|
<div
|
||||||
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
className={`absolute inset-0 flex items-center justify-center ${
|
||||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
||||||
}`} />
|
}`}
|
||||||
</div>
|
>
|
||||||
)}
|
<div
|
||||||
</div>
|
className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
||||||
|
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||||
<Avatar
|
}`}
|
||||||
src={member.avatar_url}
|
/>
|
||||||
name={member.name || ''}
|
</div>
|
||||||
size={24}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
|
||||||
{member.name}
|
|
||||||
</div>
|
|
||||||
<div className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
||||||
{member.email}
|
|
||||||
{member.pending_invitation && (
|
|
||||||
<span className="text-red-400 ml-1">(Pending)</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
||||||
<div className="text-xs">No members found</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
<Avatar
|
||||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
src={member.avatar_url}
|
||||||
<button
|
name={member.name || ''}
|
||||||
className={`
|
size={24}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
{member.email}
|
||||||
|
{member.pending_invitation && (
|
||||||
|
<span className="text-red-400 ml-1">(Pending)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs">No members found</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
transition-colors
|
transition-colors
|
||||||
${isDarkMode
|
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
|
||||||
? 'text-blue-400 hover:bg-gray-700'
|
|
||||||
: 'text-blue-600 hover:bg-blue-50'
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
onClick={handleInviteProjectMemberDrawer}
|
onClick={handleInviteProjectMemberDrawer}
|
||||||
>
|
>
|
||||||
<UserAddOutlined />
|
<UserAddOutlined />
|
||||||
Invite member
|
Invite member
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Avatar: React.FC<AvatarProps> = ({
|
|||||||
src,
|
src,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
onClick,
|
onClick,
|
||||||
style = {}
|
style = {},
|
||||||
}) => {
|
}) => {
|
||||||
// Handle both numeric and string sizes
|
// Handle both numeric and string sizes
|
||||||
const getSize = () => {
|
const getSize = () => {
|
||||||
@@ -30,7 +30,7 @@ const Avatar: React.FC<AvatarProps> = ({
|
|||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
small: { width: 24, height: 24, fontSize: '10px' },
|
small: { width: 24, height: 24, fontSize: '10px' },
|
||||||
default: { width: 32, height: 32, fontSize: '14px' },
|
default: { width: 32, height: 32, fontSize: '14px' },
|
||||||
large: { width: 48, height: 48, fontSize: '18px' }
|
large: { width: 48, height: 48, fontSize: '18px' },
|
||||||
};
|
};
|
||||||
|
|
||||||
return sizeMap[size];
|
return sizeMap[size];
|
||||||
@@ -39,13 +39,29 @@ const Avatar: React.FC<AvatarProps> = ({
|
|||||||
const sizeStyle = getSize();
|
const sizeStyle = getSize();
|
||||||
|
|
||||||
const lightColors = [
|
const lightColors = [
|
||||||
'#f56565', '#4299e1', '#48bb78', '#ed8936', '#9f7aea',
|
'#f56565',
|
||||||
'#ed64a6', '#667eea', '#38b2ac', '#f6ad55', '#4fd1c7'
|
'#4299e1',
|
||||||
|
'#48bb78',
|
||||||
|
'#ed8936',
|
||||||
|
'#9f7aea',
|
||||||
|
'#ed64a6',
|
||||||
|
'#667eea',
|
||||||
|
'#38b2ac',
|
||||||
|
'#f6ad55',
|
||||||
|
'#4fd1c7',
|
||||||
];
|
];
|
||||||
|
|
||||||
const darkColors = [
|
const darkColors = [
|
||||||
'#e53e3e', '#3182ce', '#38a169', '#dd6b20', '#805ad5',
|
'#e53e3e',
|
||||||
'#d53f8c', '#5a67d8', '#319795', '#d69e2e', '#319795'
|
'#3182ce',
|
||||||
|
'#38a169',
|
||||||
|
'#dd6b20',
|
||||||
|
'#805ad5',
|
||||||
|
'#d53f8c',
|
||||||
|
'#5a67d8',
|
||||||
|
'#319795',
|
||||||
|
'#d69e2e',
|
||||||
|
'#319795',
|
||||||
];
|
];
|
||||||
|
|
||||||
const colors = isDarkMode ? darkColors : lightColors;
|
const colors = isDarkMode ? darkColors : lightColors;
|
||||||
@@ -60,7 +76,7 @@ const Avatar: React.FC<AvatarProps> = ({
|
|||||||
const avatarStyle = {
|
const avatarStyle = {
|
||||||
...sizeStyle,
|
...sizeStyle,
|
||||||
backgroundColor: defaultBgColor,
|
backgroundColor: defaultBgColor,
|
||||||
...style
|
...style,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (src) {
|
if (src) {
|
||||||
|
|||||||
@@ -26,36 +26,43 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
|
|||||||
size = 28,
|
size = 28,
|
||||||
isDarkMode = false,
|
isDarkMode = false,
|
||||||
className = '',
|
className = '',
|
||||||
onClick
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
const stopPropagation = useCallback(
|
||||||
e.stopPropagation();
|
(e: React.MouseEvent) => {
|
||||||
onClick?.(e);
|
e.stopPropagation();
|
||||||
}, [onClick]);
|
onClick?.(e);
|
||||||
|
},
|
||||||
|
[onClick]
|
||||||
|
);
|
||||||
|
|
||||||
const renderAvatar = useCallback((member: Member, index: number) => {
|
const renderAvatar = useCallback(
|
||||||
const memberName = member.end && member.names ? member.names.join(', ') : member.name || '';
|
(member: Member, index: number) => {
|
||||||
const displayName = member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase();
|
const memberName = member.end && member.names ? member.names.join(', ') : member.name || '';
|
||||||
|
const displayName =
|
||||||
|
member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={member.team_member_id || member.id || index}
|
key={member.team_member_id || member.id || index}
|
||||||
title={memberName}
|
title={memberName}
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
name={member.name || ''}
|
|
||||||
src={member.avatar_url}
|
|
||||||
size={size}
|
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
backgroundColor={member.color_code}
|
>
|
||||||
onClick={stopPropagation}
|
<Avatar
|
||||||
className="border-2 border-white"
|
name={member.name || ''}
|
||||||
style={isDarkMode ? { borderColor: '#374151' } : {}}
|
src={member.avatar_url}
|
||||||
/>
|
size={size}
|
||||||
</Tooltip>
|
isDarkMode={isDarkMode}
|
||||||
);
|
backgroundColor={member.color_code}
|
||||||
}, [stopPropagation, size, isDarkMode]);
|
onClick={stopPropagation}
|
||||||
|
className="border-2 border-white"
|
||||||
|
style={isDarkMode ? { borderColor: '#374151' } : {}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[stopPropagation, size, isDarkMode]
|
||||||
|
);
|
||||||
|
|
||||||
const visibleMembers = useMemo(() => {
|
const visibleMembers = useMemo(() => {
|
||||||
return maxCount ? members.slice(0, maxCount) : members;
|
return maxCount ? members.slice(0, maxCount) : members;
|
||||||
@@ -77,7 +84,7 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
|
|||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
small: { width: 24, height: 24, fontSize: '10px' },
|
small: { width: 24, height: 24, fontSize: '10px' },
|
||||||
default: { width: 32, height: 32, fontSize: '14px' },
|
default: { width: 32, height: 32, fontSize: '14px' },
|
||||||
large: { width: 48, height: 48, fontSize: '18px' }
|
large: { width: 48, height: 48, fontSize: '18px' },
|
||||||
};
|
};
|
||||||
|
|
||||||
return sizeMap[size];
|
return sizeMap[size];
|
||||||
@@ -87,15 +94,10 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
|
|||||||
<div onClick={stopPropagation} className={`flex -space-x-1 ${className}`}>
|
<div onClick={stopPropagation} className={`flex -space-x-1 ${className}`}>
|
||||||
{avatarElements}
|
{avatarElements}
|
||||||
{remainingCount > 0 && (
|
{remainingCount > 0 && (
|
||||||
<Tooltip
|
<Tooltip title={`${remainingCount} more`} isDarkMode={isDarkMode}>
|
||||||
title={`${remainingCount} more`}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm border-2 cursor-pointer ${
|
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm border-2 cursor-pointer ${
|
||||||
isDarkMode
|
isDarkMode ? 'bg-gray-600 border-gray-700' : 'bg-gray-400 border-white'
|
||||||
? 'bg-gray-600 border-gray-700'
|
|
||||||
: 'bg-gray-400 border-white'
|
|
||||||
}`}
|
}`}
|
||||||
style={getSizeStyle()}
|
style={getSizeStyle()}
|
||||||
onClick={stopPropagation}
|
onClick={stopPropagation}
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElemen
|
|||||||
: 'bg-blue-500 text-white hover:bg-blue-600',
|
: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||||
danger: isDarkMode
|
danger: isDarkMode
|
||||||
? 'bg-red-600 text-white hover:bg-red-700'
|
? 'bg-red-600 text-white hover:bg-red-700'
|
||||||
: 'bg-red-500 text-white hover:bg-red-600'
|
: 'bg-red-500 text-white hover:bg-red-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
small: 'px-2 py-1 text-xs rounded-sm',
|
small: 'px-2 py-1 text-xs rounded-sm',
|
||||||
default: 'px-3 py-2 text-sm rounded-md',
|
default: 'px-3 py-2 text-sm rounded-md',
|
||||||
large: 'px-4 py-3 text-base rounded-lg'
|
large: 'px-4 py-3 text-base rounded-lg',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,7 +55,7 @@ const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElemen
|
|||||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon && <span className={children ? "mr-1" : ""}>{icon}</span>}
|
{icon && <span className={children ? 'mr-1' : ''}>{icon}</span>}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,30 +15,42 @@ const Checkbox: React.FC<CheckboxProps> = ({
|
|||||||
isDarkMode = false,
|
isDarkMode = false,
|
||||||
className = '',
|
className = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
indeterminate = false
|
indeterminate = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<label className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
|
<label
|
||||||
|
className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(e) => !disabled && onChange(e.target.checked)}
|
onChange={e => !disabled && onChange(e.target.checked)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<div className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${
|
<div
|
||||||
checked || indeterminate
|
className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${
|
||||||
? `${isDarkMode ? 'bg-blue-600 border-blue-600' : 'bg-blue-500 border-blue-500'}`
|
checked || indeterminate
|
||||||
: `${isDarkMode ? 'bg-gray-800 border-gray-600 hover:border-gray-500' : 'bg-white border-gray-300 hover:border-gray-400'}`
|
? `${isDarkMode ? 'bg-blue-600 border-blue-600' : 'bg-blue-500 border-blue-500'}`
|
||||||
} ${disabled ? 'cursor-not-allowed' : ''}`}>
|
: `${isDarkMode ? 'bg-gray-800 border-gray-600 hover:border-gray-500' : 'bg-white border-gray-300 hover:border-gray-400'}`
|
||||||
|
} ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
{checked && !indeterminate && (
|
{checked && !indeterminate && (
|
||||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{indeterminate && (
|
{indeterminate && (
|
||||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,13 +7,9 @@ interface CustomColordLabelProps {
|
|||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomColordLabel: React.FC<CustomColordLabelProps> = ({
|
const CustomColordLabel: React.FC<CustomColordLabelProps> = ({ label, isDarkMode = false }) => {
|
||||||
label,
|
const truncatedName =
|
||||||
isDarkMode = false
|
label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name;
|
||||||
}) => {
|
|
||||||
const truncatedName = label.name && label.name.length > 10
|
|
||||||
? `${label.name.substring(0, 10)}...`
|
|
||||||
: label.name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={label.name}>
|
<Tooltip title={label.name}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface CustomNumberLabelProps {
|
|||||||
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
|
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
|
||||||
labelList,
|
labelList,
|
||||||
namesString,
|
namesString,
|
||||||
isDarkMode = false
|
isDarkMode = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={labelList.join(', ')}>
|
<Tooltip title={labelList.join(', ')}>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
logger.error('Error caught by ErrorBoundary:', {
|
logger.error('Error caught by ErrorBoundary:', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
componentStack: errorInfo.componentStack
|
componentStack: errorInfo.componentStack,
|
||||||
});
|
});
|
||||||
console.error('Error caught by ErrorBoundary:', error);
|
console.error('Error caught by ErrorBoundary:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ interface LabelsSelectorProps {
|
|||||||
isDarkMode?: boolean;
|
isDarkMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
const LabelsSelector: React.FC<LabelsSelectorProps> = ({ task, isDarkMode = false }) => {
|
||||||
task,
|
|
||||||
isDarkMode = false
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
@@ -31,9 +28,11 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
|||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
|
|
||||||
const filteredLabels = useMemo(() => {
|
const filteredLabels = useMemo(() => {
|
||||||
return (labels as ITaskLabel[])?.filter(label =>
|
return (
|
||||||
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
(labels as ITaskLabel[])?.filter(label =>
|
||||||
) || [];
|
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
) || []
|
||||||
|
);
|
||||||
}, [labels, searchQuery]);
|
}, [labels, searchQuery]);
|
||||||
|
|
||||||
// Update dropdown position
|
// Update dropdown position
|
||||||
@@ -50,8 +49,12 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
|||||||
// Close dropdown when clicking outside and handle scroll
|
// Close dropdown when clicking outside and handle scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
if (
|
||||||
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -61,9 +64,11 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
|||||||
// Check if the button is still visible in the viewport
|
// Check if the button is still visible in the viewport
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
const isVisible = rect.top >= 0 && rect.left >= 0 &&
|
const isVisible =
|
||||||
rect.bottom <= window.innerHeight &&
|
rect.top >= 0 &&
|
||||||
rect.right <= window.innerWidth;
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= window.innerHeight &&
|
||||||
|
rect.right <= window.innerWidth;
|
||||||
|
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
updateDropdownPosition();
|
updateDropdownPosition();
|
||||||
@@ -114,8 +119,6 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleLabelToggle = (label: ITaskLabel) => {
|
const handleLabelToggle = (label: ITaskLabel) => {
|
||||||
const labelData = {
|
const labelData = {
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
@@ -163,128 +166,130 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
|||||||
className={`
|
className={`
|
||||||
w-5 h-5 rounded border border-dashed flex items-center justify-center
|
w-5 h-5 rounded border border-dashed flex items-center justify-center
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
${isOpen
|
${
|
||||||
? isDarkMode
|
isOpen
|
||||||
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
? isDarkMode
|
||||||
: 'border-blue-500 bg-blue-50 text-blue-600'
|
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||||
: isDarkMode
|
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
: isDarkMode
|
||||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<PlusOutlined className="text-xs" />
|
<PlusOutlined className="text-xs" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && createPortal(
|
{isOpen &&
|
||||||
<div
|
createPortal(
|
||||||
ref={dropdownRef}
|
<div
|
||||||
className={`
|
ref={dropdownRef}
|
||||||
|
className={`
|
||||||
fixed z-9999 w-72 rounded-md shadow-lg border
|
fixed z-9999 w-72 rounded-md shadow-lg border
|
||||||
${isDarkMode
|
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
|
||||||
? 'bg-gray-800 border-gray-600'
|
|
||||||
: 'bg-white border-gray-200'
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
top: dropdownPosition.top,
|
top: dropdownPosition.top,
|
||||||
left: dropdownPosition.left,
|
left: dropdownPosition.left,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Search labels..."
|
placeholder="Search labels..."
|
||||||
className={`
|
className={`
|
||||||
w-full px-2 py-1 text-xs rounded border
|
w-full px-2 py-1 text-xs rounded border
|
||||||
${isDarkMode
|
${
|
||||||
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
isDarkMode
|
||||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||||
|
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||||
}
|
}
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500
|
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labels List */}
|
{/* Labels List */}
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
{filteredLabels && filteredLabels.length > 0 ? (
|
{filteredLabels && filteredLabels.length > 0 ? (
|
||||||
filteredLabels.map((label) => (
|
filteredLabels.map(label => (
|
||||||
<div
|
<div
|
||||||
key={label.id}
|
key={label.id}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||||
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
|
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
|
||||||
`}
|
`}
|
||||||
onClick={() => handleLabelToggle(label)}
|
onClick={() => handleLabelToggle(label)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={checkLabelSelected(label.id || '')}
|
checked={checkLabelSelected(label.id || '')}
|
||||||
onChange={() => handleLabelToggle(label)}
|
onChange={() => handleLabelToggle(label)}
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full shrink-0"
|
className="w-3 h-3 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: label.color_code }}
|
style={{ backgroundColor: label.color_code }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
<div
|
||||||
{label.name}
|
className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
))
|
) : (
|
||||||
) : (
|
<div
|
||||||
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||||
<div className="text-xs">No labels found</div>
|
>
|
||||||
{searchQuery.trim() && (
|
<div className="text-xs">No labels found</div>
|
||||||
<button
|
{searchQuery.trim() && (
|
||||||
onClick={handleCreateLabel}
|
<button
|
||||||
className={`
|
onClick={handleCreateLabel}
|
||||||
|
className={`
|
||||||
mt-2 px-3 py-1 text-xs rounded border transition-colors
|
mt-2 px-3 py-1 text-xs rounded border transition-colors
|
||||||
${isDarkMode
|
${
|
||||||
? 'border-gray-600 text-gray-300 hover:bg-gray-700'
|
isDarkMode
|
||||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50'
|
? 'border-gray-600 text-gray-300 hover:bg-gray-700'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:bg-gray-50'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
Create "{searchQuery}"
|
Create "{searchQuery}"
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
<button
|
<button
|
||||||
className={`
|
className={`
|
||||||
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
transition-colors
|
transition-colors
|
||||||
${isDarkMode
|
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
|
||||||
? 'text-blue-400 hover:bg-gray-700'
|
|
||||||
: 'text-blue-600 hover:bg-blue-50'
|
|
||||||
}
|
|
||||||
`}
|
`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// TODO: Implement manage labels functionality
|
// TODO: Implement manage labels functionality
|
||||||
console.log('Manage labels clicked');
|
console.log('Manage labels clicked');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TagOutlined />
|
<TagOutlined />
|
||||||
Manage labels
|
Manage labels
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Progress: React.FC<ProgressProps> = ({
|
|||||||
strokeWidth = 2,
|
strokeWidth = 2,
|
||||||
showInfo = true,
|
showInfo = true,
|
||||||
isDarkMode = false,
|
isDarkMode = false,
|
||||||
className = ''
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
// Ensure percent is between 0 and 100
|
// Ensure percent is between 0 and 100
|
||||||
const normalizedPercent = Math.min(Math.max(percent, 0), 100);
|
const normalizedPercent = Math.min(Math.max(percent, 0), 100);
|
||||||
@@ -55,7 +55,9 @@ const Progress: React.FC<ProgressProps> = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
<span className={`absolute text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
<span
|
||||||
|
className={`absolute text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
{normalizedPercent}%
|
{normalizedPercent}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -64,12 +66,14 @@ const Progress: React.FC<ProgressProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full rounded-full h-2 ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'} ${className}`}>
|
<div
|
||||||
|
className={`w-full rounded-full h-2 ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'} ${className}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="h-2 rounded-full transition-all duration-300"
|
className="h-2 rounded-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
width: `${normalizedPercent}%`,
|
width: `${normalizedPercent}%`,
|
||||||
backgroundColor: normalizedPercent === 100 ? '#52c41a' : strokeColor
|
backgroundColor: normalizedPercent === 100 ? '#52c41a' : strokeColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ const Tag: React.FC<TagProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
size = 'default',
|
size = 'default',
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
isDarkMode = false
|
isDarkMode = false,
|
||||||
}) => {
|
}) => {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
small: 'px-1 py-0.5 text-xs',
|
small: 'px-1 py-0.5 text-xs',
|
||||||
default: 'px-2 py-1 text-xs'
|
default: 'px-2 py-1 text-xs',
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseClasses = `inline-flex items-center font-medium rounded-sm ${sizeClasses[size]}`;
|
const baseClasses = `inline-flex items-center font-medium rounded-sm ${sizeClasses[size]}`;
|
||||||
@@ -33,7 +33,7 @@ const Tag: React.FC<TagProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
borderColor: backgroundColor,
|
borderColor: backgroundColor,
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
backgroundColor: 'transparent'
|
backgroundColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -42,10 +42,7 @@ const Tag: React.FC<TagProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`${baseClasses} ${className}`} style={{ backgroundColor, color }}>
|
||||||
className={`${baseClasses} ${className}`}
|
|
||||||
style={{ backgroundColor, color }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,19 +13,21 @@ const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
children,
|
children,
|
||||||
isDarkMode = false,
|
isDarkMode = false,
|
||||||
placement = 'top',
|
placement = 'top',
|
||||||
className = ''
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const placementClasses = {
|
const placementClasses = {
|
||||||
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
|
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
|
||||||
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
|
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
|
||||||
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
|
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
|
||||||
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2'
|
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative group ${className}`}>
|
<div className={`relative group ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
<div className={`absolute ${placementClasses[placement]} px-2 py-1 text-xs text-white ${isDarkMode ? 'bg-gray-700' : 'bg-gray-900'} rounded-sm shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-50 pointer-events-none min-w-max`}>
|
<div
|
||||||
|
className={`absolute ${placementClasses[placement]} px-2 py-1 text-xs text-white ${isDarkMode ? 'bg-gray-700' : 'bg-gray-900'} rounded-sm shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-50 pointer-events-none min-w-max`}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export const TasksStep: React.FC<Props> = ({ onEnter, styles, isDarkMode }) => {
|
|||||||
|
|
||||||
const updateTask = (id: number, value: string) => {
|
const updateTask = (id: number, value: string) => {
|
||||||
const sanitizedValue = sanitizeInput(value);
|
const sanitizedValue = sanitizeInput(value);
|
||||||
dispatch(setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task))));
|
dispatch(
|
||||||
|
setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task)))
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [subscriptionType, setSubscriptionType] = useState<string>(SUBSCRIPTION_STATUS.TRIALING);
|
const [subscriptionType, setSubscriptionType] = useState<string>(SUBSCRIPTION_STATUS.TRIALING);
|
||||||
|
|
||||||
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(state => state.adminCenterReducer);
|
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(
|
||||||
|
state => state.adminCenterReducer
|
||||||
|
);
|
||||||
|
|
||||||
const formatBytes = useMemo(
|
const formatBytes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
|
import {
|
||||||
|
fetchBillingInfo,
|
||||||
|
fetchFreePlanSettings,
|
||||||
|
} from '@/features/admin-center/admin-center.slice';
|
||||||
|
|
||||||
import CurrentPlanDetails from './current-plan-details/current-plan-details';
|
import CurrentPlanDetails from './current-plan-details/current-plan-details';
|
||||||
import AccountStorage from './account-storage/account-storage';
|
import AccountStorage from './account-storage/account-storage';
|
||||||
@@ -68,10 +71,7 @@ const CurrentBill: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
<Card
|
<Card title={<span style={titleStyle}>{t('invoices')}</span>} style={{ marginTop: '16px' }}>
|
||||||
title={<span style={titleStyle}>{t('invoices')}</span>}
|
|
||||||
style={{ marginTop: '16px' }}
|
|
||||||
>
|
|
||||||
<InvoicesTable />
|
<InvoicesTable />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +92,8 @@ const CurrentBill: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
renderMobileView()
|
renderMobileView()
|
||||||
)}
|
)}
|
||||||
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderChargesAndInvoices()}
|
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
||||||
|
renderChargesAndInvoices()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,20 @@ import {
|
|||||||
} from '@/shared/worklenz-analytics-events';
|
} from '@/shared/worklenz-analytics-events';
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { Button, Card, Flex, Modal, Space, Tooltip, Typography, Statistic, Select, Form, Row, Col } from 'antd/es';
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
Statistic,
|
||||||
|
Select,
|
||||||
|
Form,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
} from 'antd/es';
|
||||||
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
|
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
|
||||||
import {
|
import {
|
||||||
fetchBillingInfo,
|
fetchBillingInfo,
|
||||||
@@ -44,8 +57,9 @@ const CurrentPlanDetails = () => {
|
|||||||
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
type SeatOption = { label: string; value: number | string };
|
type SeatOption = { label: string; value: number | string };
|
||||||
const seatCountOptions: SeatOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]
|
const seatCountOptions: SeatOption[] = [
|
||||||
.map(value => ({ label: value.toString(), value }));
|
1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
|
||||||
|
].map(value => ({ label: value.toString(), value }));
|
||||||
seatCountOptions.push({ label: '100+', value: '100+' });
|
seatCountOptions.push({ label: '100+', value: '100+' });
|
||||||
|
|
||||||
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
|
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
|
||||||
@@ -127,8 +141,10 @@ const CurrentPlanDetails = () => {
|
|||||||
|
|
||||||
const shouldShowAddSeats = () => {
|
const shouldShowAddSeats = () => {
|
||||||
if (!billingInfo) return false;
|
if (!billingInfo) return false;
|
||||||
return billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
return (
|
||||||
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE;
|
billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
||||||
|
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderExtra = () => {
|
const renderExtra = () => {
|
||||||
@@ -232,12 +248,11 @@ const CurrentPlanDetails = () => {
|
|||||||
<Typography.Text>
|
<Typography.Text>
|
||||||
{isExpired
|
{isExpired
|
||||||
? t('trialExpired', {
|
? t('trialExpired', {
|
||||||
trial_expire_string: getExpirationMessage(trialExpireDate)
|
trial_expire_string: getExpirationMessage(trialExpireDate),
|
||||||
})
|
})
|
||||||
: t('trialInProgress', {
|
: t('trialInProgress', {
|
||||||
trial_expire_string: getExpirationMessage(trialExpireDate)
|
trial_expire_string: getExpirationMessage(trialExpireDate),
|
||||||
})
|
})}
|
||||||
}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -268,7 +283,6 @@ const CurrentPlanDetails = () => {
|
|||||||
{billingInfo?.billing_type === 'year'
|
{billingInfo?.billing_type === 'year'
|
||||||
? billingInfo.unit_price_per_month
|
? billingInfo.unit_price_per_month
|
||||||
: billingInfo?.unit_price}
|
: billingInfo?.unit_price}
|
||||||
|
|
||||||
{t('perMonthPerUser')}
|
{t('perMonthPerUser')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -308,16 +322,24 @@ const CurrentPlanDetails = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderCreditSubscriptionInfo = () => {
|
const renderCreditSubscriptionInfo = () => {
|
||||||
return <Flex vertical>
|
return (
|
||||||
<Typography.Text strong>{t('creditPlan','Credit Plan')}</Typography.Text>
|
<Flex vertical>
|
||||||
</Flex>
|
<Typography.Text strong>{t('creditPlan', 'Credit Plan')}</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCustomSubscriptionInfo = () => {
|
const renderCustomSubscriptionInfo = () => {
|
||||||
return <Flex vertical>
|
return (
|
||||||
<Typography.Text strong>{t('customPlan','Custom Plan')}</Typography.Text>
|
<Flex vertical>
|
||||||
<Typography.Text>{t('planValidTill','Your plan is valid till {{date}}',{date: billingInfo?.valid_till_date})}</Typography.Text>
|
<Typography.Text strong>{t('customPlan', 'Custom Plan')}</Typography.Text>
|
||||||
</Flex>
|
<Typography.Text>
|
||||||
|
{t('planValidTill', 'Your plan is valid till {{date}}', {
|
||||||
|
date: billingInfo?.valid_till_date,
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -326,7 +348,6 @@ const CurrentPlanDetails = () => {
|
|||||||
title={
|
title={
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{
|
style={{
|
||||||
|
|
||||||
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
@@ -340,12 +361,16 @@ const CurrentPlanDetails = () => {
|
|||||||
>
|
>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<div style={{ marginBottom: '14px' }}>
|
<div style={{ marginBottom: '14px' }}>
|
||||||
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL && renderLtdDetails()}
|
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL &&
|
||||||
|
renderLtdDetails()}
|
||||||
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
|
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
|
||||||
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
|
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
|
||||||
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderPaddleSubscriptionInfo()}
|
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
||||||
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT && renderCreditSubscriptionInfo()}
|
renderPaddleSubscriptionInfo()}
|
||||||
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM && renderCustomSubscriptionInfo()}
|
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT &&
|
||||||
|
renderCreditSubscriptionInfo()}
|
||||||
|
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM &&
|
||||||
|
renderCustomSubscriptionInfo()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldShowRedeemButton() && (
|
{shouldShowRedeemButton() && (
|
||||||
@@ -380,16 +405,20 @@ const CurrentPlanDetails = () => {
|
|||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
|
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
|
||||||
<Typography.Paragraph style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}>
|
<Typography.Paragraph
|
||||||
{t('purchaseSeatsText','To continue, you\'ll need to purchase additional seats.')}
|
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
||||||
{t('currentSeatsText','You currently have {{seats}} seats available.',{seats: billingInfo?.total_seats})}
|
{t('currentSeatsText', 'You currently have {{seats}} seats available.', {
|
||||||
|
seats: billingInfo?.total_seats,
|
||||||
|
})}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
|
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
|
||||||
{t('selectSeatsText','Please select the number of additional seats to purchase.')}
|
{t('selectSeatsText', 'Please select the number of additional seats to purchase.')}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
@@ -413,17 +442,14 @@ const CurrentPlanDetails = () => {
|
|||||||
minWidth: '100px',
|
minWidth: '100px',
|
||||||
backgroundColor: '#1890ff',
|
backgroundColor: '#1890ff',
|
||||||
borderColor: '#1890ff',
|
borderColor: '#1890ff',
|
||||||
borderRadius: '2px'
|
borderRadius: '2px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('purchase','Purchase')}
|
{t('purchase', 'Purchase')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button type="primary" size="middle">
|
||||||
type="primary"
|
{t('contactSales', 'Contact sales')}
|
||||||
size="middle"
|
|
||||||
>
|
|
||||||
{t('contactSales','Contact sales')}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Card, Col, Flex, Form, Row, Select, Tag, Tooltip, Typography, message } from 'antd/es';
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd/es';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||||
@@ -264,7 +276,6 @@ const UpgradePlans = () => {
|
|||||||
const isSelected = (cardIndex: IPaddlePlans) =>
|
const isSelected = (cardIndex: IPaddlePlans) =>
|
||||||
selectedPlan === cardIndex ? { border: '2px solid #1890ff' } : {};
|
selectedPlan === cardIndex ? { border: '2px solid #1890ff' } : {};
|
||||||
|
|
||||||
|
|
||||||
const cardStyles = {
|
const cardStyles = {
|
||||||
title: {
|
title: {
|
||||||
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
||||||
@@ -363,7 +374,6 @@ const UpgradePlans = () => {
|
|||||||
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
|
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
|
||||||
onClick={() => setSelectedCard(paddlePlans.FREE)}
|
onClick={() => setSelectedCard(paddlePlans.FREE)}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div style={cardStyles.priceContainer}>
|
<div style={cardStyles.priceContainer}>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<Typography.Title level={1}>$ 0.00</Typography.Title>
|
<Typography.Title level={1}>$ 0.00</Typography.Title>
|
||||||
@@ -389,7 +399,6 @@ const UpgradePlans = () => {
|
|||||||
<Card
|
<Card
|
||||||
style={{ ...isSelected(paddlePlans.ANNUAL), height: '100%' }}
|
style={{ ...isSelected(paddlePlans.ANNUAL), height: '100%' }}
|
||||||
hoverable
|
hoverable
|
||||||
|
|
||||||
title={
|
title={
|
||||||
<span style={cardStyles.title}>
|
<span style={cardStyles.title}>
|
||||||
{t('annualPlan')}{' '}
|
{t('annualPlan')}{' '}
|
||||||
@@ -401,7 +410,6 @@ const UpgradePlans = () => {
|
|||||||
onClick={() => setSelectedCard(paddlePlans.ANNUAL)}
|
onClick={() => setSelectedCard(paddlePlans.ANNUAL)}
|
||||||
>
|
>
|
||||||
<div style={cardStyles.priceContainer}>
|
<div style={cardStyles.priceContainer}>
|
||||||
|
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<Typography.Title level={1}>$ {plans.annual_price}</Typography.Title>
|
<Typography.Title level={1}>$ {plans.annual_price}</Typography.Title>
|
||||||
<Typography.Text>seat / month</Typography.Text>
|
<Typography.Text>seat / month</Typography.Text>
|
||||||
@@ -442,7 +450,6 @@ const UpgradePlans = () => {
|
|||||||
hoverable
|
hoverable
|
||||||
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
|
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
|
||||||
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
|
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
|
||||||
|
|
||||||
>
|
>
|
||||||
<div style={cardStyles.priceContainer}>
|
<div style={cardStyles.priceContainer}>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
@@ -501,7 +508,9 @@ const UpgradePlans = () => {
|
|||||||
onClick={continueWithPaddlePlan}
|
onClick={continueWithPaddlePlan}
|
||||||
disabled={billingInfo?.plan_id === plans.annual_plan_id}
|
disabled={billingInfo?.plan_id === plans.annual_plan_id}
|
||||||
>
|
>
|
||||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('annualPlan')}) : t('continueWith', {plan: t('annualPlan')})}
|
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||||
|
? t('changeToPlan', { plan: t('annualPlan') })
|
||||||
|
: t('continueWith', { plan: t('annualPlan') })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selectedPlan === paddlePlans.MONTHLY && (
|
{selectedPlan === paddlePlans.MONTHLY && (
|
||||||
@@ -512,7 +521,9 @@ const UpgradePlans = () => {
|
|||||||
onClick={continueWithPaddlePlan}
|
onClick={continueWithPaddlePlan}
|
||||||
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
|
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
|
||||||
>
|
>
|
||||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('monthlyPlan')}) : t('continueWith', {plan: t('monthlyPlan')})}
|
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||||
|
? t('changeToPlan', { plan: t('monthlyPlan') })
|
||||||
|
: t('continueWith', { plan: t('monthlyPlan') })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -75,11 +75,7 @@ const Configuration: React.FC = () => {
|
|||||||
}
|
}
|
||||||
style={{ marginTop: '16px' }}
|
style={{ marginTop: '16px' }}
|
||||||
>
|
>
|
||||||
<Form
|
<Form form={form} initialValues={configuration} onFinish={handleSave}>
|
||||||
form={form}
|
|
||||||
initialValues={configuration}
|
|
||||||
onFinish={handleSave}
|
|
||||||
>
|
|
||||||
<Row>
|
<Row>
|
||||||
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
|
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -180,7 +176,6 @@ const Configuration: React.FC = () => {
|
|||||||
showSearch
|
showSearch
|
||||||
placeholder="Country"
|
placeholder="Country"
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
|
|
||||||
allowClear
|
allowClear
|
||||||
options={countryOptions}
|
options={countryOptions}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
|
|||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
teamMembers: teamData?.team_members || []
|
teamMembers: teamData?.team_members || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await adminCenterApiService.updateTeam(teamId, body);
|
const response = await adminCenterApiService.updateTeam(teamId, body);
|
||||||
@@ -120,17 +120,18 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
|
|||||||
|
|
||||||
setTeamData({
|
setTeamData({
|
||||||
...teamData,
|
...teamData,
|
||||||
team_members: updatedMembers
|
team_members: updatedMembers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDisabled = record.role_name === 'Owner' || record.pending_invitation;
|
const isDisabled = record.role_name === 'Owner' || record.pending_invitation;
|
||||||
const tooltipTitle = record.role_name === 'Owner'
|
const tooltipTitle =
|
||||||
? t('cannotChangeOwnerRole')
|
record.role_name === 'Owner'
|
||||||
: record.pending_invitation
|
? t('cannotChangeOwnerRole')
|
||||||
? t('pendingInvitation')
|
: record.pending_invitation
|
||||||
: '';
|
? t('pendingInvitation')
|
||||||
|
: '';
|
||||||
|
|
||||||
const selectComponent = (
|
const selectComponent = (
|
||||||
<Select
|
<Select
|
||||||
@@ -145,9 +146,7 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{isDisabled ? (
|
{isDisabled ? (
|
||||||
<Tooltip title={tooltipTitle}>
|
<Tooltip title={tooltipTitle}>{selectComponent}</Tooltip>
|
||||||
{selectComponent}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
) : (
|
||||||
selectComponent
|
selectComponent
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,31 +12,34 @@ const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderAvatar = useCallback((member: InlineMember, index: number) => (
|
const renderAvatar = useCallback(
|
||||||
<Tooltip
|
(member: InlineMember, index: number) => (
|
||||||
key={member.team_member_id || index}
|
<Tooltip
|
||||||
title={member.end && member.names ? member.names.join(', ') : member.name}
|
key={member.team_member_id || index}
|
||||||
>
|
title={member.end && member.names ? member.names.join(', ') : member.name}
|
||||||
{member.avatar_url ? (
|
>
|
||||||
<span onClick={stopPropagation}>
|
{member.avatar_url ? (
|
||||||
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
<span onClick={stopPropagation}>
|
||||||
</span>
|
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
|
||||||
) : (
|
</span>
|
||||||
<span onClick={stopPropagation}>
|
) : (
|
||||||
<Avatar
|
<span onClick={stopPropagation}>
|
||||||
size={28}
|
<Avatar
|
||||||
key={member.team_member_id || index}
|
size={28}
|
||||||
style={{
|
key={member.team_member_id || index}
|
||||||
backgroundColor: member.color_code || '#ececec',
|
style={{
|
||||||
fontSize: '14px',
|
backgroundColor: member.color_code || '#ececec',
|
||||||
}}
|
fontSize: '14px',
|
||||||
>
|
}}
|
||||||
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
>
|
||||||
</Avatar>
|
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
|
||||||
</span>
|
</Avatar>
|
||||||
)}
|
</span>
|
||||||
</Tooltip>
|
)}
|
||||||
), [stopPropagation]);
|
</Tooltip>
|
||||||
|
),
|
||||||
|
[stopPropagation]
|
||||||
|
);
|
||||||
|
|
||||||
const visibleMembers = useMemo(() => {
|
const visibleMembers = useMemo(() => {
|
||||||
return maxCount ? members.slice(0, maxCount) : members;
|
return maxCount ? members.slice(0, maxCount) : members;
|
||||||
@@ -48,9 +51,7 @@ const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={stopPropagation}>
|
<div onClick={stopPropagation}>
|
||||||
<Avatar.Group>
|
<Avatar.Group>{avatarElements}</Avatar.Group>
|
||||||
{avatarElements}
|
|
||||||
</Avatar.Group>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
|
|||||||
return assignees?.includes(memberId);
|
return assignees?.includes(memberId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const membersDropdownContent = (
|
const membersDropdownContent = (
|
||||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
@@ -143,16 +142,16 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<Typography.Text>{member.name}</Typography.Text>
|
<Typography.Text>{member.name}</Typography.Text>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{member.email}
|
{member.email}
|
||||||
{member.pending_invitation && (
|
{member.pending_invitation && (
|
||||||
<Typography.Text type="danger" style={{ fontSize: 10 }}>
|
<Typography.Text type="danger" style={{ fontSize: 10 }}>
|
||||||
({t('pendingInvitation')})
|
({t('pendingInvitation')})
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -201,7 +200,7 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
|
|||||||
type="dashed"
|
type="dashed"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
icon={
|
icon={
|
||||||
<PlusOutlined
|
<PlusOutlined
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const CustomAvatarGroup = ({ task, sectionId }: CustomAvatarGroupProps) => {
|
|||||||
<Flex
|
<Flex
|
||||||
gap={4}
|
gap={4}
|
||||||
align="center"
|
align="center"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const CustomDueDatePicker = ({
|
|||||||
width: 26,
|
width: 26,
|
||||||
height: 26,
|
height: 26,
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
e.stopPropagation(); // Keep this as a backup
|
e.stopPropagation(); // Keep this as a backup
|
||||||
setIsDatePickerOpen(true);
|
setIsDatePickerOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ const PrioritySection = ({ task }: PrioritySectionProps) => {
|
|||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
style: {
|
style: {
|
||||||
color: themeMode === 'dark' ? selectedPriority.color_code_dark : selectedPriority.color_code,
|
color:
|
||||||
|
themeMode === 'dark' ? selectedPriority.color_code_dark : selectedPriority.color_code,
|
||||||
marginRight: '0.25rem',
|
marginRight: '0.25rem',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -40,9 +41,19 @@ const PrioritySection = ({ task }: PrioritySectionProps) => {
|
|||||||
case 'Low':
|
case 'Low':
|
||||||
return <MinusOutlined {...iconProps} />;
|
return <MinusOutlined {...iconProps} />;
|
||||||
case 'Medium':
|
case 'Medium':
|
||||||
return <PauseOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
|
return (
|
||||||
|
<PauseOutlined
|
||||||
|
{...iconProps}
|
||||||
|
style={{ ...iconProps.style, transform: 'rotate(90deg)' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'High':
|
case 'High':
|
||||||
return <DoubleLeftOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
|
return (
|
||||||
|
<DoubleLeftOutlined
|
||||||
|
{...iconProps}
|
||||||
|
style={{ ...iconProps.style, transform: 'rotate(90deg)' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -50,11 +61,7 @@ const PrioritySection = ({ task }: PrioritySectionProps) => {
|
|||||||
|
|
||||||
if (!task.priority || !selectedPriority) return null;
|
if (!task.priority || !selectedPriority) return null;
|
||||||
|
|
||||||
return (
|
return <Flex gap={4}>{priorityIcon}</Flex>;
|
||||||
<Flex gap={4}>
|
|
||||||
{priorityIcon}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PrioritySection;
|
export default PrioritySection;
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import React, { Suspense } from 'react';
|
|||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
// Lazy load chart components to reduce initial bundle size
|
// Lazy load chart components to reduce initial bundle size
|
||||||
const LazyBar = React.lazy(() => import('react-chartjs-2').then(module => ({ default: module.Bar })));
|
const LazyBar = React.lazy(() =>
|
||||||
const LazyDoughnut = React.lazy(() => import('react-chartjs-2').then(module => ({ default: module.Doughnut })));
|
import('react-chartjs-2').then(module => ({ default: module.Bar }))
|
||||||
|
);
|
||||||
|
const LazyDoughnut = React.lazy(() =>
|
||||||
|
import('react-chartjs-2').then(module => ({ default: module.Doughnut }))
|
||||||
|
);
|
||||||
|
|
||||||
interface ChartLoaderProps {
|
interface ChartLoaderProps {
|
||||||
type: 'bar' | 'doughnut';
|
type: 'bar' | 'doughnut';
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ const Collapsible = ({ isOpen, children, className = '', color }: CollapsiblePro
|
|||||||
marginTop: '6px',
|
marginTop: '6px',
|
||||||
}}
|
}}
|
||||||
className={`transition-all duration-300 ease-in-out ${
|
className={`transition-all duration-300 ease-in-out ${
|
||||||
isOpen ? 'max-h-[2000px] opacity-100 overflow-x-scroll' : 'max-h-0 opacity-0 overflow-hidden'
|
isOpen
|
||||||
|
? 'max-h-[2000px] opacity-100 overflow-x-scroll'
|
||||||
|
: 'max-h-0 opacity-0 overflow-hidden'
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from 'antd';
|
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from 'antd';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { toggleInviteMemberDrawer, triggerTeamMembersRefresh } from '../../../features/settings/member/memberSlice';
|
import {
|
||||||
|
toggleInviteMemberDrawer,
|
||||||
|
triggerTeamMembersRefresh,
|
||||||
|
} from '../../../features/settings/member/memberSlice';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||||
|
|||||||
@@ -91,4 +91,3 @@
|
|||||||
.custom-template-list .selected-custom-template:hover {
|
.custom-template-list .selected-custom-template:hover {
|
||||||
background-color: var(--color-paleBlue);
|
background-color: var(--color-paleBlue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ import {
|
|||||||
pointerWithin,
|
pointerWithin,
|
||||||
rectIntersection,
|
rectIntersection,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
SortableContext,
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import {
|
||||||
fetchEnhancedKanbanGroups,
|
fetchEnhancedKanbanGroups,
|
||||||
@@ -49,7 +46,9 @@ import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
|||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
|
||||||
// Import the TaskListFilters component
|
// Import the TaskListFilters component
|
||||||
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
|
const TaskListFilters = React.lazy(
|
||||||
|
() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')
|
||||||
|
);
|
||||||
interface EnhancedKanbanBoardProps {
|
interface EnhancedKanbanBoardProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -57,19 +56,17 @@ interface EnhancedKanbanBoardProps {
|
|||||||
|
|
||||||
const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, className = '' }) => {
|
const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, className = '' }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const {
|
const { taskGroups, loadingGroups, error, dragState, performanceMetrics } = useSelector(
|
||||||
taskGroups,
|
(state: RootState) => state.enhancedKanbanReducer
|
||||||
loadingGroups,
|
);
|
||||||
error,
|
|
||||||
dragState,
|
|
||||||
performanceMetrics
|
|
||||||
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
|
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
const authService = useAuthService();
|
const authService = useAuthService();
|
||||||
const teamId = authService.getCurrentSession()?.team_id;
|
const teamId = authService.getCurrentSession()?.team_id;
|
||||||
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
|
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
|
||||||
const project = useAppSelector((state: RootState) => state.projectReducer.project);
|
const project = useAppSelector((state: RootState) => state.projectReducer.project);
|
||||||
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
const { statusCategories, status: existingStatuses } = useAppSelector(
|
||||||
|
state => state.taskStatusReducer
|
||||||
|
);
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
|
||||||
// Load filter data
|
// Load filter data
|
||||||
@@ -106,22 +103,18 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
}, [dispatch, projectId]);
|
}, [dispatch, projectId]);
|
||||||
|
|
||||||
// Get all task IDs for sortable context
|
// Get all task IDs for sortable context
|
||||||
const allTaskIds = useMemo(() =>
|
const allTaskIds = useMemo(
|
||||||
taskGroups.flatMap(group => group.tasks.map(task => task.id!)),
|
() => taskGroups.flatMap(group => group.tasks.map(task => task.id!)),
|
||||||
[taskGroups]
|
|
||||||
);
|
|
||||||
const allGroupIds = useMemo(() =>
|
|
||||||
taskGroups.map(group => group.id),
|
|
||||||
[taskGroups]
|
[taskGroups]
|
||||||
);
|
);
|
||||||
|
const allGroupIds = useMemo(() => taskGroups.map(group => group.id), [taskGroups]);
|
||||||
|
|
||||||
// Enhanced collision detection
|
// Enhanced collision detection
|
||||||
const collisionDetectionStrategy = (args: any) => {
|
const collisionDetectionStrategy = (args: any) => {
|
||||||
// First, let's see if we're colliding with any droppable areas
|
// First, let's see if we're colliding with any droppable areas
|
||||||
const pointerIntersections = pointerWithin(args);
|
const pointerIntersections = pointerWithin(args);
|
||||||
const intersections = pointerIntersections.length > 0
|
const intersections =
|
||||||
? pointerIntersections
|
pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
|
||||||
: rectIntersection(args);
|
|
||||||
|
|
||||||
let overId = getFirstCollision(intersections, 'id');
|
let overId = getFirstCollision(intersections, 'id');
|
||||||
|
|
||||||
@@ -162,11 +155,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
setActiveGroup(foundGroup);
|
setActiveGroup(foundGroup);
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
|
|
||||||
dispatch(setDragState({
|
dispatch(
|
||||||
activeTaskId: null,
|
setDragState({
|
||||||
activeGroupId: activeId,
|
activeTaskId: null,
|
||||||
isDragging: true,
|
activeGroupId: activeId,
|
||||||
}));
|
isDragging: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Dragging a task
|
// Dragging a task
|
||||||
let foundTask = null;
|
let foundTask = null;
|
||||||
@@ -184,11 +179,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
setActiveTask(foundTask);
|
setActiveTask(foundTask);
|
||||||
setActiveGroup(null);
|
setActiveGroup(null);
|
||||||
|
|
||||||
dispatch(setDragState({
|
dispatch(
|
||||||
activeTaskId: activeId,
|
setDragState({
|
||||||
activeGroupId: foundGroup?.id || null,
|
activeTaskId: activeId,
|
||||||
isDragging: true,
|
activeGroupId: foundGroup?.id || null,
|
||||||
}));
|
isDragging: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,12 +217,14 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
setOverId(null);
|
setOverId(null);
|
||||||
|
|
||||||
// Reset Redux drag state
|
// Reset Redux drag state
|
||||||
dispatch(setDragState({
|
dispatch(
|
||||||
activeTaskId: null,
|
setDragState({
|
||||||
activeGroupId: null,
|
activeTaskId: null,
|
||||||
overId: null,
|
activeGroupId: null,
|
||||||
isDragging: false,
|
overId: null,
|
||||||
}));
|
isDragging: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
@@ -258,7 +257,7 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
// Call API to update status order
|
// Call API to update status order
|
||||||
try {
|
try {
|
||||||
const requestBody: ITaskStatusCreateRequest = {
|
const requestBody: ITaskStatusCreateRequest = {
|
||||||
status_order: columnOrder
|
status_order: columnOrder,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
||||||
@@ -267,7 +266,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
const revertedGroups = [...reorderedGroups];
|
const revertedGroups = [...reorderedGroups];
|
||||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||||
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
|
dispatch(
|
||||||
|
reorderGroups({
|
||||||
|
fromIndex: toIndex,
|
||||||
|
toIndex: fromIndex,
|
||||||
|
reorderedGroups: revertedGroups,
|
||||||
|
})
|
||||||
|
);
|
||||||
alertService.error('Failed to update column order', 'Please try again');
|
alertService.error('Failed to update column order', 'Please try again');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -275,7 +280,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
const revertedGroups = [...reorderedGroups];
|
const revertedGroups = [...reorderedGroups];
|
||||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||||
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
|
dispatch(
|
||||||
|
reorderGroups({
|
||||||
|
fromIndex: toIndex,
|
||||||
|
toIndex: fromIndex,
|
||||||
|
reorderedGroups: revertedGroups,
|
||||||
|
})
|
||||||
|
);
|
||||||
alertService.error('Failed to update column order', 'Please try again');
|
alertService.error('Failed to update column order', 'Please try again');
|
||||||
logger.error('Failed to update column order', error);
|
logger.error('Failed to update column order', error);
|
||||||
}
|
}
|
||||||
@@ -338,24 +349,28 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Synchronous UI update
|
// Synchronous UI update
|
||||||
dispatch(reorderTasks({
|
dispatch(
|
||||||
activeGroupId: sourceGroup.id,
|
reorderTasks({
|
||||||
overGroupId: targetGroup.id,
|
activeGroupId: sourceGroup.id,
|
||||||
fromIndex: sourceIndex,
|
overGroupId: targetGroup.id,
|
||||||
toIndex: targetIndex,
|
fromIndex: sourceIndex,
|
||||||
task: movedTask,
|
toIndex: targetIndex,
|
||||||
updatedSourceTasks,
|
task: movedTask,
|
||||||
updatedTargetTasks,
|
updatedSourceTasks,
|
||||||
}));
|
updatedTargetTasks,
|
||||||
dispatch(reorderEnhancedKanbanTasks({
|
})
|
||||||
activeGroupId: sourceGroup.id,
|
);
|
||||||
overGroupId: targetGroup.id,
|
dispatch(
|
||||||
fromIndex: sourceIndex,
|
reorderEnhancedKanbanTasks({
|
||||||
toIndex: targetIndex,
|
activeGroupId: sourceGroup.id,
|
||||||
task: movedTask,
|
overGroupId: targetGroup.id,
|
||||||
updatedSourceTasks,
|
fromIndex: sourceIndex,
|
||||||
updatedTargetTasks,
|
toIndex: targetIndex,
|
||||||
}) as any);
|
task: movedTask,
|
||||||
|
updatedSourceTasks,
|
||||||
|
updatedTargetTasks,
|
||||||
|
}) as any
|
||||||
|
);
|
||||||
|
|
||||||
// --- Socket emit for task sort order ---
|
// --- Socket emit for task sort order ---
|
||||||
if (socket && projectId && movedTask) {
|
if (socket && projectId && movedTask) {
|
||||||
@@ -368,7 +383,10 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
|
|||||||
toSortOrder = -1;
|
toSortOrder = -1;
|
||||||
toLastIndex = true;
|
toLastIndex = true;
|
||||||
} else if (targetGroup.tasks[targetIndex]) {
|
} else if (targetGroup.tasks[targetIndex]) {
|
||||||
toSortOrder = typeof targetGroup.tasks[targetIndex].sort_order === 'number' ? targetGroup.tasks[targetIndex].sort_order! : -1;
|
toSortOrder =
|
||||||
|
typeof targetGroup.tasks[targetIndex].sort_order === 'number'
|
||||||
|
? targetGroup.tasks[targetIndex].sort_order!
|
||||||
|
: -1;
|
||||||
toLastIndex = false;
|
toLastIndex = false;
|
||||||
} else if (targetGroup.tasks.length > 0) {
|
} else if (targetGroup.tasks.length > 0) {
|
||||||
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
|
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { nanoid } from '@reduxjs/toolkit';
|
|||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { IGroupBy, fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
import {
|
||||||
|
IGroupBy,
|
||||||
|
fetchEnhancedKanbanGroups,
|
||||||
|
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||||
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||||
@@ -19,10 +22,12 @@ import useIsProjectManager from '@/hooks/useIsProjectManager';
|
|||||||
const EnhancedKanbanCreateSection: React.FC = () => {
|
const EnhancedKanbanCreateSection: React.FC = () => {
|
||||||
const { t } = useTranslation('kanban-board');
|
const { t } = useTranslation('kanban-board');
|
||||||
|
|
||||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const { projectId } = useAppSelector((state) => state.projectReducer);
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
const groupBy = useAppSelector((state) => state.enhancedKanbanReducer.groupBy);
|
const groupBy = useAppSelector(state => state.enhancedKanbanReducer.groupBy);
|
||||||
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
const { statusCategories, status: existingStatuses } = useAppSelector(
|
||||||
|
state => state.taskStatusReducer
|
||||||
|
);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
|
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
@@ -60,9 +65,9 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
|||||||
|
|
||||||
if (groupBy === IGroupBy.STATUS && projectId) {
|
if (groupBy === IGroupBy.STATUS && projectId) {
|
||||||
// Find the "To do" category
|
// Find the "To do" category
|
||||||
const todoCategory = statusCategories.find(category =>
|
const todoCategory = statusCategories.find(
|
||||||
category.name?.toLowerCase() === 'to do' ||
|
category =>
|
||||||
category.name?.toLowerCase() === 'todo'
|
category.name?.toLowerCase() === 'to do' || category.name?.toLowerCase() === 'todo'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (todoCategory && todoCategory.id) {
|
if (todoCategory && todoCategory.id) {
|
||||||
@@ -75,7 +80,9 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the status
|
// Create the status
|
||||||
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
|
const response = await dispatch(
|
||||||
|
createStatus({ body, currentProjectId: projectId })
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
if (response.done && response.body) {
|
if (response.done && response.body) {
|
||||||
// Refresh the board to show the new section
|
// Refresh the board to show the new section
|
||||||
|
|||||||
@@ -85,22 +85,27 @@ const EnhancedKanbanCreateSubtaskCard = ({
|
|||||||
}, 0);
|
}, 0);
|
||||||
if (task.parent_task_id) {
|
if (task.parent_task_id) {
|
||||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||||
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
|
socket?.once(
|
||||||
id: string;
|
SocketEvents.GET_TASK_PROGRESS.toString(),
|
||||||
complete_ratio: number;
|
(data: {
|
||||||
completed_count: number;
|
id: string;
|
||||||
total_tasks_count: number;
|
complete_ratio: number;
|
||||||
parent_task: string;
|
completed_count: number;
|
||||||
}) => {
|
total_tasks_count: number;
|
||||||
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
|
parent_task: string;
|
||||||
dispatch(updateEnhancedKanbanTaskProgress({
|
}) => {
|
||||||
id: task.id || '',
|
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
|
||||||
complete_ratio: data.complete_ratio,
|
dispatch(
|
||||||
completed_count: data.completed_count,
|
updateEnhancedKanbanTaskProgress({
|
||||||
total_tasks_count: data.total_tasks_count,
|
id: task.id || '',
|
||||||
parent_task: data.parent_task,
|
complete_ratio: data.complete_ratio,
|
||||||
}));
|
completed_count: data.completed_count,
|
||||||
});
|
total_tasks_count: data.total_tasks_count,
|
||||||
|
parent_task: data.parent_task,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -143,7 +148,7 @@ const EnhancedKanbanCreateSubtaskCard = ({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
// className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
// className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
||||||
onBlur={handleCancelNewCard}
|
onBlur={handleCancelNewCard}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -127,7 +127,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dropPulse {
|
@keyframes dropPulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transform: scaleX(0.8);
|
transform: scaleX(0.8);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.enhanced-kanban-task-card.drop-target::before {
|
.enhanced-kanban-task-card.drop-target::before {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -2px;
|
top: -2px;
|
||||||
left: -2px;
|
left: -2px;
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dropTargetPulse {
|
@keyframes dropTargetPulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import { ForkOutlined } from '@ant-design/icons';
|
|||||||
import { Dayjs } from 'dayjs';
|
import { Dayjs } from 'dayjs';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
|
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
|
||||||
import { fetchBoardSubTasks, toggleTaskExpansion } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
import {
|
||||||
|
fetchBoardSubTasks,
|
||||||
|
toggleTaskExpansion,
|
||||||
|
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||||
import { Divider } from 'antd';
|
import { Divider } from 'antd';
|
||||||
import { List } from 'antd';
|
import { List } from 'antd';
|
||||||
import { Skeleton } from 'antd';
|
import { Skeleton } from 'antd';
|
||||||
@@ -46,227 +49,233 @@ const PRIORITY_COLORS = {
|
|||||||
low: '#52c41a',
|
low: '#52c41a',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo(({
|
const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo(
|
||||||
task,
|
({ task, sectionId, isActive = false, isDragOverlay = false, isDropTarget = false }) => {
|
||||||
sectionId,
|
const dispatch = useAppDispatch();
|
||||||
isActive = false,
|
const { t } = useTranslation('kanban-board');
|
||||||
isDragOverlay = false,
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
isDropTarget = false
|
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
|
||||||
}) => {
|
const [dueDate, setDueDate] = useState<Dayjs | null>(
|
||||||
const dispatch = useAppDispatch();
|
task?.end_date ? dayjs(task?.end_date) : null
|
||||||
const { t } = useTranslation('kanban-board');
|
);
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
|
||||||
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
|
|
||||||
const [dueDate, setDueDate] = useState<Dayjs | null>(
|
|
||||||
task?.end_date ? dayjs(task?.end_date) : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
attributes,
|
id: task.id!,
|
||||||
listeners,
|
data: {
|
||||||
setNodeRef,
|
type: 'task',
|
||||||
transform,
|
task,
|
||||||
transition,
|
},
|
||||||
isDragging,
|
disabled: isDragOverlay,
|
||||||
} = useSortable({
|
animateLayoutChanges: defaultAnimateLayoutChanges,
|
||||||
id: task.id!,
|
});
|
||||||
data: {
|
|
||||||
type: 'task',
|
|
||||||
task,
|
|
||||||
},
|
|
||||||
disabled: isDragOverlay,
|
|
||||||
animateLayoutChanges: defaultAnimateLayoutChanges,
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
const handleCardClick = useCallback(
|
||||||
// Prevent the event from propagating to parent elements
|
(e: React.MouseEvent, id: string) => {
|
||||||
e.stopPropagation();
|
// Prevent the event from propagating to parent elements
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
// Don't handle click if we're dragging
|
// Don't handle click if we're dragging
|
||||||
if (isDragging) return;
|
if (isDragging) return;
|
||||||
dispatch(setSelectedTaskId(id));
|
dispatch(setSelectedTaskId(id));
|
||||||
dispatch(setShowTaskDrawer(true));
|
dispatch(setShowTaskDrawer(true));
|
||||||
}, [dispatch, isDragging]);
|
},
|
||||||
|
[dispatch, isDragging]
|
||||||
|
);
|
||||||
|
|
||||||
const renderLabels = useMemo(() => {
|
const renderLabels = useMemo(() => {
|
||||||
if (!task?.labels?.length) return null;
|
if (!task?.labels?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{task.labels.slice(0, 2).map((label: any) => (
|
||||||
|
<Tag key={label.id} style={{ marginRight: '2px' }} color={label?.color_code}>
|
||||||
|
<span style={{ color: themeMode === 'dark' ? '#383838' : '', fontSize: 10 }}>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [task.labels, themeMode]);
|
||||||
|
|
||||||
|
const handleSubTaskExpand = useCallback(() => {
|
||||||
|
if (task && task.id && projectId) {
|
||||||
|
// Check if subtasks are already loaded and we have subtask data
|
||||||
|
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count > 0) {
|
||||||
|
// If subtasks are already loaded, just toggle visibility
|
||||||
|
dispatch(toggleTaskExpansion(task.id));
|
||||||
|
} else if (task.sub_tasks_count > 0) {
|
||||||
|
// If we have a subtask count but no loaded subtasks, fetch them
|
||||||
|
dispatch(toggleTaskExpansion(task.id));
|
||||||
|
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
||||||
|
} else {
|
||||||
|
// If no subtasks exist, just toggle visibility (will show empty state)
|
||||||
|
dispatch(toggleTaskExpansion(task.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [task, projectId, dispatch]);
|
||||||
|
|
||||||
|
const handleSubtaskButtonClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSubTaskExpand();
|
||||||
|
},
|
||||||
|
[handleSubTaskExpand]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowNewSubtaskCard(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{task.labels.slice(0, 2).map((label: any) => (
|
ref={setNodeRef}
|
||||||
<Tag key={label.id} style={{ marginRight: '2px' }} color={label?.color_code}>
|
style={style}
|
||||||
<span style={{ color: themeMode === 'dark' ? '#383838' : '', fontSize: 10 }}>
|
className={`enhanced-kanban-task-card ${isActive ? 'active' : ''} ${isDragging ? 'dragging' : ''} ${isDragOverlay ? 'drag-overlay' : ''} ${isDropTarget ? 'drop-target' : ''}`}
|
||||||
{label.name}
|
{...attributes}
|
||||||
</span>
|
{...listeners}
|
||||||
</Tag>
|
>
|
||||||
))}
|
<div className="task-content" onClick={e => handleCardClick(e, task.id || '')}>
|
||||||
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
|
<Flex align="center" justify="space-between" className="mb-2">
|
||||||
</>
|
<Flex>{renderLabels}</Flex>
|
||||||
);
|
|
||||||
}, [task.labels, themeMode]);
|
|
||||||
|
|
||||||
|
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
|
||||||
|
<Progress
|
||||||
const handleSubTaskExpand = useCallback(() => {
|
type="circle"
|
||||||
if (task && task.id && projectId) {
|
percent={task?.complete_ratio}
|
||||||
// Check if subtasks are already loaded and we have subtask data
|
size={24}
|
||||||
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count > 0) {
|
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
|
||||||
// If subtasks are already loaded, just toggle visibility
|
/>
|
||||||
dispatch(toggleTaskExpansion(task.id));
|
</Tooltip>
|
||||||
} else if (task.sub_tasks_count > 0) {
|
|
||||||
// If we have a subtask count but no loaded subtasks, fetch them
|
|
||||||
dispatch(toggleTaskExpansion(task.id));
|
|
||||||
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
|
||||||
} else {
|
|
||||||
// If no subtasks exist, just toggle visibility (will show empty state)
|
|
||||||
dispatch(toggleTaskExpansion(task.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [task, projectId, dispatch]);
|
|
||||||
|
|
||||||
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleSubTaskExpand();
|
|
||||||
}, [handleSubTaskExpand]);
|
|
||||||
|
|
||||||
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowNewSubtaskCard(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={`enhanced-kanban-task-card ${isActive ? 'active' : ''} ${isDragging ? 'dragging' : ''} ${isDragOverlay ? 'drag-overlay' : ''} ${isDropTarget ? 'drop-target' : ''}`}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
>
|
|
||||||
<div className="task-content" onClick={e => handleCardClick(e, task.id || '')}>
|
|
||||||
<Flex align="center" justify="space-between" className="mb-2">
|
|
||||||
<Flex>
|
|
||||||
{renderLabels}
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
|
|
||||||
<Progress type="circle" percent={task?.complete_ratio} size={24} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
|
|
||||||
</Tooltip>
|
|
||||||
</Flex>
|
|
||||||
<Flex gap={4} align="center">
|
|
||||||
{/* Action Icons */}
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: task.priority_color || '#d9d9d9' }}
|
|
||||||
/>
|
|
||||||
<Typography.Text
|
|
||||||
style={{ fontWeight: 500 }}
|
|
||||||
ellipsis={{ tooltip: task.name }}
|
|
||||||
>
|
|
||||||
{task.name}
|
|
||||||
</Typography.Text>
|
|
||||||
</Flex>
|
|
||||||
<Flex
|
|
||||||
align="center"
|
|
||||||
justify="space-between"
|
|
||||||
style={{
|
|
||||||
marginBlock: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Flex align="center" gap={2}>
|
|
||||||
<AvatarGroup
|
|
||||||
members={task.names || []}
|
|
||||||
maxCount={3}
|
|
||||||
isDarkMode={themeMode === 'dark'}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
<LazyAssigneeSelectorWrapper task={task} groupId={sectionId} isDarkMode={themeMode === 'dark'} />
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap={4} align="center">
|
<Flex gap={4} align="center">
|
||||||
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
{/* Action Icons */}
|
||||||
|
<div
|
||||||
{/* Subtask Section */}
|
className="w-2 h-2 rounded-full"
|
||||||
<Button
|
style={{ backgroundColor: task.priority_color || '#d9d9d9' }}
|
||||||
onClick={handleSubtaskButtonClick}
|
/>
|
||||||
size="small"
|
<Typography.Text style={{ fontWeight: 500 }} ellipsis={{ tooltip: task.name }}>
|
||||||
style={{
|
{task.name}
|
||||||
padding: 0,
|
</Typography.Text>
|
||||||
}}
|
|
||||||
type="text"
|
|
||||||
>
|
|
||||||
<Tag
|
|
||||||
bordered={false}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
margin: 0,
|
|
||||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ForkOutlined rotate={90} />
|
|
||||||
<span>{task.sub_tasks_count}</span>
|
|
||||||
{task.show_sub_tasks ? <CaretDownFilled /> : <CaretRightFilled />}
|
|
||||||
</Tag>
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
<Flex
|
||||||
<Flex vertical gap={8}>
|
align="center"
|
||||||
{task.show_sub_tasks && (
|
justify="space-between"
|
||||||
<Flex vertical>
|
style={{
|
||||||
<Divider style={{ marginBlock: 0 }} />
|
marginBlock: 8,
|
||||||
<List>
|
}}
|
||||||
{task.sub_tasks_loading && (
|
>
|
||||||
<List.Item>
|
<Flex align="center" gap={2}>
|
||||||
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
|
<AvatarGroup
|
||||||
</List.Item>
|
members={task.names || []}
|
||||||
)}
|
maxCount={3}
|
||||||
|
isDarkMode={themeMode === 'dark'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<LazyAssigneeSelectorWrapper
|
||||||
|
task={task}
|
||||||
|
groupId={sectionId}
|
||||||
|
isDarkMode={themeMode === 'dark'}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap={4} align="center">
|
||||||
|
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
||||||
|
|
||||||
{!task.sub_tasks_loading && task?.sub_tasks && task.sub_tasks.length > 0 &&
|
{/* Subtask Section */}
|
||||||
task.sub_tasks.map((subtask: any) => (
|
|
||||||
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!task.sub_tasks_loading && (!task?.sub_tasks || task.sub_tasks.length === 0) && task.sub_tasks_count === 0 && (
|
|
||||||
<List.Item>
|
|
||||||
<div style={{ padding: '8px 0', color: '#999', fontSize: '12px' }}>
|
|
||||||
{t('noSubtasks', 'No subtasks')}
|
|
||||||
</div>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNewSubtaskCard && (
|
|
||||||
<EnhancedKanbanCreateSubtaskCard
|
|
||||||
sectionId={sectionId}
|
|
||||||
parentTaskId={task.id || ''}
|
|
||||||
setShowNewSubtaskCard={setShowNewSubtaskCard}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
onClick={handleSubtaskButtonClick}
|
||||||
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
width: 'fit-content',
|
padding: 0,
|
||||||
borderRadius: 6,
|
|
||||||
boxShadow: 'none',
|
|
||||||
}}
|
}}
|
||||||
icon={<PlusOutlined />}
|
type="text"
|
||||||
onClick={handleAddSubtaskClick}
|
|
||||||
>
|
>
|
||||||
{t('addSubtask', 'Add Subtask')}
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
margin: 0,
|
||||||
|
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ForkOutlined rotate={90} />
|
||||||
|
<span>{task.sub_tasks_count}</span>
|
||||||
|
{task.show_sub_tasks ? <CaretDownFilled /> : <CaretRightFilled />}
|
||||||
|
</Tag>
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
</Flex>
|
||||||
</Flex>
|
<Flex vertical gap={8}>
|
||||||
|
{task.show_sub_tasks && (
|
||||||
|
<Flex vertical>
|
||||||
|
<Divider style={{ marginBlock: 0 }} />
|
||||||
|
<List>
|
||||||
|
{task.sub_tasks_loading && (
|
||||||
|
<List.Item>
|
||||||
|
<Skeleton
|
||||||
|
active
|
||||||
|
paragraph={{ rows: 2 }}
|
||||||
|
title={false}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!task.sub_tasks_loading &&
|
||||||
|
task?.sub_tasks &&
|
||||||
|
task.sub_tasks.length > 0 &&
|
||||||
|
task.sub_tasks.map((subtask: any) => (
|
||||||
|
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!task.sub_tasks_loading &&
|
||||||
|
(!task?.sub_tasks || task.sub_tasks.length === 0) &&
|
||||||
|
task.sub_tasks_count === 0 && (
|
||||||
|
<List.Item>
|
||||||
|
<div style={{ padding: '8px 0', color: '#999', fontSize: '12px' }}>
|
||||||
|
{t('noSubtasks', 'No subtasks')}
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewSubtaskCard && (
|
||||||
|
<EnhancedKanbanCreateSubtaskCard
|
||||||
|
sectionId={sectionId}
|
||||||
|
parentTaskId={task.id || ''}
|
||||||
|
setShowNewSubtaskCard={setShowNewSubtaskCard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
borderRadius: 6,
|
||||||
|
boxShadow: 'none',
|
||||||
|
}}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddSubtaskClick}
|
||||||
|
>
|
||||||
|
{t('addSubtask', 'Add Subtask')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
export default EnhancedKanbanTaskCard;
|
export default EnhancedKanbanTaskCard;
|
||||||
@@ -21,11 +21,16 @@ const PerformanceMonitor: React.FC = () => {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'critical': return 'red';
|
case 'critical':
|
||||||
case 'warning': return 'orange';
|
return 'red';
|
||||||
case 'good': return 'blue';
|
case 'warning':
|
||||||
case 'excellent': return 'green';
|
return 'orange';
|
||||||
default: return 'default';
|
case 'good':
|
||||||
|
return 'blue';
|
||||||
|
case 'excellent':
|
||||||
|
return 'green';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,45 +22,54 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
|
|||||||
onTaskRender,
|
onTaskRender,
|
||||||
}) => {
|
}) => {
|
||||||
// Memoize task data to prevent unnecessary re-renders
|
// Memoize task data to prevent unnecessary re-renders
|
||||||
const taskData = useMemo(() => ({
|
const taskData = useMemo(
|
||||||
tasks,
|
() => ({
|
||||||
activeTaskId,
|
tasks,
|
||||||
overId,
|
activeTaskId,
|
||||||
onTaskRender,
|
overId,
|
||||||
}), [tasks, activeTaskId, overId, onTaskRender]);
|
onTaskRender,
|
||||||
|
}),
|
||||||
|
[tasks, activeTaskId, overId, onTaskRender]
|
||||||
|
);
|
||||||
|
|
||||||
// Row renderer for virtualized list
|
// Row renderer for virtualized list
|
||||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
const Row = useCallback(
|
||||||
const task = tasks[index];
|
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||||
if (!task) return null;
|
const task = tasks[index];
|
||||||
|
if (!task) return null;
|
||||||
|
|
||||||
// Call onTaskRender callback if provided
|
// Call onTaskRender callback if provided
|
||||||
onTaskRender?.(task, index);
|
onTaskRender?.(task, index);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnhancedKanbanTaskCard
|
<EnhancedKanbanTaskCard
|
||||||
task={task}
|
task={task}
|
||||||
isActive={task.id === activeTaskId}
|
isActive={task.id === activeTaskId}
|
||||||
isDropTarget={overId === task.id}
|
isDropTarget={overId === task.id}
|
||||||
sectionId={task.status || 'default'}
|
sectionId={task.status || 'default'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [tasks, activeTaskId, overId, onTaskRender]);
|
},
|
||||||
|
[tasks, activeTaskId, overId, onTaskRender]
|
||||||
|
);
|
||||||
|
|
||||||
// Memoize the list component to prevent unnecessary re-renders
|
// Memoize the list component to prevent unnecessary re-renders
|
||||||
const VirtualizedList = useMemo(() => (
|
const VirtualizedList = useMemo(
|
||||||
<List
|
() => (
|
||||||
height={height}
|
<List
|
||||||
width="100%"
|
height={height}
|
||||||
itemCount={tasks.length}
|
width="100%"
|
||||||
itemSize={itemHeight}
|
itemCount={tasks.length}
|
||||||
itemData={taskData}
|
itemSize={itemHeight}
|
||||||
overscanCount={10} // Increased overscan for smoother scrolling experience
|
itemData={taskData}
|
||||||
className="virtualized-task-list"
|
overscanCount={10} // Increased overscan for smoother scrolling experience
|
||||||
>
|
className="virtualized-task-list"
|
||||||
{Row}
|
>
|
||||||
</List>
|
{Row}
|
||||||
), [height, tasks.length, itemHeight, taskData, Row]);
|
</List>
|
||||||
|
),
|
||||||
|
[height, tasks.length, itemHeight, taskData, Row]
|
||||||
|
);
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
|
|||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const {
|
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
refetch
|
|
||||||
} = useGetMyTasksQuery(homeTasksConfig, {
|
|
||||||
skip: false, // Ensure this query runs
|
skip: false, // Ensure this query runs
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +1,111 @@
|
|||||||
import { useSocket } from "@/socket/socketContext";
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { IProjectTask } from "@/types/project/projectTasksViewModel.types";
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { DatePicker } from "antd";
|
import { DatePicker } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import calendar from 'dayjs/plugin/calendar';
|
import calendar from 'dayjs/plugin/calendar';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useAppSelector } from "@/hooks/useAppSelector";
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useGetMyTasksQuery } from "@/api/home-page/home-page.api.service";
|
import { useGetMyTasksQuery } from '@/api/home-page/home-page.api.service';
|
||||||
import { getUserSession } from "@/utils/session-helper";
|
import { getUserSession } from '@/utils/session-helper';
|
||||||
|
|
||||||
// Extend dayjs with the calendar plugin
|
// Extend dayjs with the calendar plugin
|
||||||
dayjs.extend(calendar);
|
dayjs.extend(calendar);
|
||||||
|
|
||||||
type HomeTasksDatePickerProps = {
|
type HomeTasksDatePickerProps = {
|
||||||
record: IProjectTask;
|
record: IProjectTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
|
const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
|
||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const { t } = useTranslation('home');
|
const { t } = useTranslation('home');
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
|
||||||
skip: false
|
skip: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use useMemo to avoid re-renders when record.end_date is the same
|
||||||
|
const initialDate = useMemo(
|
||||||
|
() => (record.end_date ? dayjs(record.end_date) : null),
|
||||||
|
[record.end_date]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
||||||
|
|
||||||
|
// Update selected date when record changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedDate(initialDate);
|
||||||
|
}, [initialDate]);
|
||||||
|
|
||||||
|
const handleChangeReceived = (value: any) => {
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
socket?.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
|
||||||
|
socket?.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
|
||||||
|
return () => {
|
||||||
|
socket?.removeListener(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
|
||||||
|
socket?.removeListener(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
|
||||||
|
};
|
||||||
|
}, [connected]);
|
||||||
|
|
||||||
|
const handleEndDateChanged = (value: Dayjs | null, task: IProjectTask) => {
|
||||||
|
setSelectedDate(value);
|
||||||
|
if (!task.id) return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
task_id: task.id,
|
||||||
|
end_date: value?.format('YYYY-MM-DD'),
|
||||||
|
parent_task: task.parent_task_id,
|
||||||
|
time_zone: getUserSession()?.timezone_name
|
||||||
|
? getUserSession()?.timezone_name
|
||||||
|
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
};
|
||||||
|
socket?.emit(SocketEvents.TASK_END_DATE_CHANGE.toString(), JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to dynamically format the date based on the calendar rules
|
||||||
|
const getFormattedDate = (date: Dayjs | null) => {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
return date.calendar(null, {
|
||||||
|
sameDay: '[Today]',
|
||||||
|
nextDay: '[Tomorrow]',
|
||||||
|
nextWeek: 'MMM DD',
|
||||||
|
lastDay: '[Yesterday]',
|
||||||
|
lastWeek: 'MMM DD',
|
||||||
|
sameElse: date.year() === dayjs().year() ? 'MMM DD' : 'MMM DD, YYYY',
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Use useMemo to avoid re-renders when record.end_date is the same
|
return (
|
||||||
const initialDate = useMemo(() =>
|
<DatePicker
|
||||||
record.end_date ? dayjs(record.end_date) : null
|
allowClear
|
||||||
, [record.end_date]);
|
disabledDate={
|
||||||
|
record.start_date ? current => current.isBefore(dayjs(record.start_date)) : undefined
|
||||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
|
}
|
||||||
|
placeholder={t('tasks.dueDatePlaceholder')}
|
||||||
// Update selected date when record changes
|
value={selectedDate}
|
||||||
useEffect(() => {
|
onChange={value => handleEndDateChanged(value || null, record || null)}
|
||||||
setSelectedDate(initialDate);
|
format={value => getFormattedDate(value)} // Dynamically format the displayed value
|
||||||
}, [initialDate]);
|
style={{
|
||||||
|
color: selectedDate
|
||||||
const handleChangeReceived = (value: any) => {
|
? selectedDate.isSame(dayjs(), 'day') || selectedDate.isSame(dayjs().add(1, 'day'), 'day')
|
||||||
refetch();
|
? '#52c41a'
|
||||||
};
|
: selectedDate.isAfter(dayjs().add(1, 'day'), 'day')
|
||||||
|
? undefined
|
||||||
useEffect(() => {
|
: '#ff4d4f'
|
||||||
socket?.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
|
: undefined,
|
||||||
socket?.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
|
width: '125px', // Ensure the input takes full width
|
||||||
return () => {
|
}}
|
||||||
socket?.removeListener(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
|
inputReadOnly // Prevent manual input to avoid overflow issues
|
||||||
socket?.removeListener(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
|
variant={'borderless'} // Make the DatePicker borderless
|
||||||
};
|
suffixIcon={null}
|
||||||
}, [connected]);
|
/>
|
||||||
|
);
|
||||||
const handleEndDateChanged = (value: Dayjs | null, task: IProjectTask) => {
|
|
||||||
setSelectedDate(value);
|
|
||||||
if (!task.id) return;
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
task_id: task.id,
|
|
||||||
end_date: value?.format('YYYY-MM-DD'),
|
|
||||||
parent_task: task.parent_task_id,
|
|
||||||
time_zone: getUserSession()?.timezone_name
|
|
||||||
? getUserSession()?.timezone_name
|
|
||||||
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
||||||
};
|
|
||||||
socket?.emit(SocketEvents.TASK_END_DATE_CHANGE.toString(), JSON.stringify(body));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to dynamically format the date based on the calendar rules
|
|
||||||
const getFormattedDate = (date: Dayjs | null) => {
|
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
return date.calendar(null, {
|
|
||||||
sameDay: '[Today]',
|
|
||||||
nextDay: '[Tomorrow]',
|
|
||||||
nextWeek: 'MMM DD',
|
|
||||||
lastDay: '[Yesterday]',
|
|
||||||
lastWeek: 'MMM DD',
|
|
||||||
sameElse: date.year() === dayjs().year() ? 'MMM DD' : 'MMM DD, YYYY',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DatePicker
|
|
||||||
allowClear
|
|
||||||
disabledDate={
|
|
||||||
record.start_date ? current => current.isBefore(dayjs(record.start_date)) : undefined
|
|
||||||
}
|
|
||||||
placeholder={t('tasks.dueDatePlaceholder')}
|
|
||||||
value={selectedDate}
|
|
||||||
onChange={value => handleEndDateChanged(value || null, record || null)}
|
|
||||||
format={(value) => getFormattedDate(value)} // Dynamically format the displayed value
|
|
||||||
style={{
|
|
||||||
color: selectedDate
|
|
||||||
? selectedDate.isSame(dayjs(), 'day') || selectedDate.isSame(dayjs().add(1, 'day'), 'day')
|
|
||||||
? '#52c41a'
|
|
||||||
: selectedDate.isAfter(dayjs().add(1, 'day'), 'day')
|
|
||||||
? undefined
|
|
||||||
: '#ff4d4f'
|
|
||||||
: undefined,
|
|
||||||
width: '125px', // Ensure the input takes full width
|
|
||||||
}}
|
|
||||||
inputReadOnly // Prevent manual input to avoid overflow issues
|
|
||||||
variant={'borderless'} // Make the DatePicker borderless
|
|
||||||
suffixIcon={null}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HomeTasksDatePicker;
|
export default HomeTasksDatePicker;
|
||||||
@@ -6,47 +6,40 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
|||||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||||
|
|
||||||
interface SortableKanbanGroupProps {
|
interface SortableKanbanGroupProps {
|
||||||
group: ITaskListGroup;
|
group: ITaskListGroup;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentGrouping: IGroupBy;
|
currentGrouping: IGroupBy;
|
||||||
selectedTaskIds: string[];
|
selectedTaskIds: string[];
|
||||||
onAddTask?: (groupId: string) => void;
|
onAddTask?: (groupId: string) => void;
|
||||||
onToggleCollapse?: (groupId: string) => void;
|
onToggleCollapse?: (groupId: string) => void;
|
||||||
onSelectTask?: (taskId: string, selected: boolean) => void;
|
onSelectTask?: (taskId: string, selected: boolean) => void;
|
||||||
onToggleSubtasks?: (taskId: string) => void;
|
onToggleSubtasks?: (taskId: string) => void;
|
||||||
activeTaskId?: string | null;
|
activeTaskId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableKanbanGroup: React.FC<SortableKanbanGroupProps> = (props) => {
|
const SortableKanbanGroup: React.FC<SortableKanbanGroupProps> = props => {
|
||||||
const { group, activeTaskId } = props;
|
const { group, activeTaskId } = props;
|
||||||
const {
|
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
|
||||||
setNodeRef,
|
id: group.id,
|
||||||
attributes,
|
data: { type: 'group', groupId: group.id },
|
||||||
listeners,
|
});
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: group.id,
|
|
||||||
data: { type: 'group', groupId: group.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
zIndex: isDragging ? 10 : undefined,
|
zIndex: isDragging ? 10 : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style}>
|
<div ref={setNodeRef} style={style}>
|
||||||
<KanbanGroup
|
<KanbanGroup
|
||||||
{...props}
|
{...props}
|
||||||
dragHandleProps={{ ...attributes, ...listeners }}
|
dragHandleProps={{ ...attributes, ...listeners }}
|
||||||
activeTaskId={activeTaskId}
|
activeTaskId={activeTaskId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SortableKanbanGroup;
|
export default SortableKanbanGroup;
|
||||||
@@ -10,130 +10,122 @@ import KanbanTaskCard from './kanbanTaskCard';
|
|||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface TaskGroupProps {
|
interface TaskGroupProps {
|
||||||
group: ITaskListGroup;
|
group: ITaskListGroup;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentGrouping: IGroupBy;
|
currentGrouping: IGroupBy;
|
||||||
selectedTaskIds: string[];
|
selectedTaskIds: string[];
|
||||||
onAddTask?: (groupId: string) => void;
|
onAddTask?: (groupId: string) => void;
|
||||||
onToggleCollapse?: (groupId: string) => void;
|
onToggleCollapse?: (groupId: string) => void;
|
||||||
onSelectTask?: (taskId: string, selected: boolean) => void;
|
onSelectTask?: (taskId: string, selected: boolean) => void;
|
||||||
onToggleSubtasks?: (taskId: string) => void;
|
onToggleSubtasks?: (taskId: string) => void;
|
||||||
dragHandleProps?: any;
|
dragHandleProps?: any;
|
||||||
activeTaskId?: string | null;
|
activeTaskId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KanbanGroup: React.FC<TaskGroupProps> = ({
|
const KanbanGroup: React.FC<TaskGroupProps> = ({
|
||||||
group,
|
group,
|
||||||
projectId,
|
projectId,
|
||||||
currentGrouping,
|
currentGrouping,
|
||||||
selectedTaskIds,
|
selectedTaskIds,
|
||||||
onAddTask,
|
onAddTask,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
onToggleSubtasks,
|
onToggleSubtasks,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
data: {
|
data: {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
groupId: group.id,
|
groupId: group.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get task IDs for sortable context
|
// Get task IDs for sortable context
|
||||||
const taskIds = group.tasks.map(task => task.id!);
|
const taskIds = group.tasks.map(task => task.id!);
|
||||||
|
|
||||||
// Get group color based on grouping type
|
// Get group color based on grouping type
|
||||||
const getGroupColor = () => {
|
const getGroupColor = () => {
|
||||||
if (group.color_code) return group.color_code;
|
if (group.color_code) return group.color_code;
|
||||||
switch (currentGrouping) {
|
switch (currentGrouping) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a';
|
return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a';
|
||||||
case 'priority':
|
case 'priority':
|
||||||
return group.id === 'critical'
|
return group.id === 'critical'
|
||||||
? '#ff4d4f'
|
? '#ff4d4f'
|
||||||
: group.id === 'high'
|
: group.id === 'high'
|
||||||
? '#fa8c16'
|
? '#fa8c16'
|
||||||
: group.id === 'medium'
|
: group.id === 'medium'
|
||||||
? '#faad14'
|
? '#faad14'
|
||||||
: '#52c41a';
|
: '#52c41a';
|
||||||
case 'phase':
|
case 'phase':
|
||||||
return '#722ed1';
|
return '#722ed1';
|
||||||
default:
|
default:
|
||||||
return '#d9d9d9';
|
return '#d9d9d9';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTask = () => {
|
const handleAddTask = () => {
|
||||||
onAddTask?.(group.id);
|
onAddTask?.(group.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={setNodeRef} className={`kanban-group-column${isOver ? ' drag-over' : ''}`}>
|
||||||
ref={setNodeRef}
|
{/* Group Header */}
|
||||||
className={`kanban-group-column${isOver ? ' drag-over' : ''}`}
|
<div className="kanban-group-header" style={{ backgroundColor: getGroupColor() }}>
|
||||||
>
|
{/* Drag handle for column */}
|
||||||
{/* Group Header */}
|
<Button
|
||||||
<div className="kanban-group-header" style={{ backgroundColor: getGroupColor() }}>
|
type="text"
|
||||||
{/* Drag handle for column */}
|
size="small"
|
||||||
<Button
|
icon={<MenuOutlined />}
|
||||||
type="text"
|
className="kanban-group-drag-handle"
|
||||||
size="small"
|
style={{ marginRight: 8, cursor: 'grab', opacity: 0.7 }}
|
||||||
icon={<MenuOutlined />}
|
{...(dragHandleProps || {})}
|
||||||
className="kanban-group-drag-handle"
|
/>
|
||||||
style={{ marginRight: 8, cursor: 'grab', opacity: 0.7 }}
|
<Text strong className="kanban-group-header-text">
|
||||||
{...(dragHandleProps || {})}
|
{group.name} <span className="kanban-group-count">({group.tasks.length})</span>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks as Cards */}
|
||||||
|
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="kanban-group-tasks">
|
||||||
|
{group.tasks.length === 0 ? (
|
||||||
|
<div className="kanban-group-empty">
|
||||||
|
<Text type="secondary">No tasks in this group</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
group.tasks.map((task, index) =>
|
||||||
|
task.id === activeTaskId ? (
|
||||||
|
<div key={task.id} className="kanban-task-card kanban-task-card-placeholder" />
|
||||||
|
) : (
|
||||||
|
<KanbanTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={group.id}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={selectedTaskIds.includes(task.id!)}
|
||||||
|
index={index}
|
||||||
|
onSelect={onSelectTask}
|
||||||
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
/>
|
/>
|
||||||
<Text strong className="kanban-group-header-text">
|
)
|
||||||
{group.name} <span className="kanban-group-count">({group.tasks.length})</span>
|
)
|
||||||
</Text>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
{/* Tasks as Cards */}
|
{/* Add Task Button */}
|
||||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
<div className="kanban-group-add-task">
|
||||||
<div className="kanban-group-tasks">
|
<Button type="dashed" icon={<PlusOutlined />} block onClick={handleAddTask}>
|
||||||
{group.tasks.length === 0 ? (
|
Add Task
|
||||||
<div className="kanban-group-empty">
|
</Button>
|
||||||
<Text type="secondary">No tasks in this group</Text>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
group.tasks.map((task, index) => (
|
|
||||||
task.id === activeTaskId ? (
|
|
||||||
<div key={task.id} className="kanban-task-card kanban-task-card-placeholder" />
|
|
||||||
) : (
|
|
||||||
<KanbanTaskCard
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
projectId={projectId}
|
|
||||||
groupId={group.id}
|
|
||||||
currentGrouping={currentGrouping}
|
|
||||||
isSelected={selectedTaskIds.includes(task.id!)}
|
|
||||||
index={index}
|
|
||||||
onSelect={onSelectTask}
|
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
|
|
||||||
{/* Add Task Button */}
|
<style>{`
|
||||||
<div className="kanban-group-add-task">
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
block
|
|
||||||
onClick={handleAddTask}
|
|
||||||
>
|
|
||||||
Add Task
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.kanban-group-column {
|
.kanban-group-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -221,8 +213,8 @@ const KanbanGroup: React.FC<TaskGroupProps> = ({
|
|||||||
border: 2px dashed var(--task-drag-over-border, #40a9ff);
|
border: 2px dashed var(--task-drag-over-border, #40a9ff);
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default KanbanGroup;
|
export default KanbanGroup;
|
||||||
|
|||||||
@@ -36,14 +36,7 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onToggleSubtasks,
|
onToggleSubtasks,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({
|
|
||||||
id: task.id!,
|
id: task.id!,
|
||||||
data: {
|
data: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@@ -93,7 +86,10 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
/>
|
/>
|
||||||
<Text strong className={`kanban-task-title${task.complete_ratio === 100 ? ' kanban-task-completed' : ''}`}>
|
<Text
|
||||||
|
strong
|
||||||
|
className={`kanban-task-title${task.complete_ratio === 100 ? ' kanban-task-completed' : ''}`}
|
||||||
|
>
|
||||||
{task.name}
|
{task.name}
|
||||||
</Text>
|
</Text>
|
||||||
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
|
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
|
||||||
@@ -112,15 +108,23 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
{/* Task Key and Status */}
|
{/* Task Key and Status */}
|
||||||
<div className="kanban-task-row">
|
<div className="kanban-task-row">
|
||||||
{task.task_key && (
|
{task.task_key && (
|
||||||
<Text code className="kanban-task-key">{task.task_key}</Text>
|
<Text code className="kanban-task-key">
|
||||||
|
{task.task_key}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
{task.status_name && (
|
{task.status_name && (
|
||||||
<Tag className="kanban-task-status" style={{ backgroundColor: task.status_color, color: 'white', marginLeft: 8 }}>
|
<Tag
|
||||||
|
className="kanban-task-status"
|
||||||
|
style={{ backgroundColor: task.status_color, color: 'white', marginLeft: 8 }}
|
||||||
|
>
|
||||||
{task.status_name}
|
{task.status_name}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
{task.priority_name && (
|
{task.priority_name && (
|
||||||
<Tag className="kanban-task-priority" style={{ backgroundColor: task.priority_color, color: 'white', marginLeft: 8 }}>
|
<Tag
|
||||||
|
className="kanban-task-priority"
|
||||||
|
style={{ backgroundColor: task.priority_color, color: 'white', marginLeft: 8 }}
|
||||||
|
>
|
||||||
{task.priority_name}
|
{task.priority_name}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
@@ -139,7 +143,11 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<Text type={dueDate.color as any} className="kanban-task-due-date" style={{ marginLeft: 12 }}>
|
<Text
|
||||||
|
type={dueDate.color as any}
|
||||||
|
className="kanban-task-due-date"
|
||||||
|
style={{ marginLeft: 12 }}
|
||||||
|
>
|
||||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||||
{dueDate.text}
|
{dueDate.text}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -149,7 +157,7 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
<div className="kanban-task-row">
|
<div className="kanban-task-row">
|
||||||
{task.assignees && task.assignees.length > 0 && (
|
{task.assignees && task.assignees.length > 0 && (
|
||||||
<Avatar.Group size="small" maxCount={3}>
|
<Avatar.Group size="small" maxCount={3}>
|
||||||
{task.assignees.map((assignee) => (
|
{task.assignees.map(assignee => (
|
||||||
<Tooltip key={assignee.id} title={assignee.name}>
|
<Tooltip key={assignee.id} title={assignee.name}>
|
||||||
<Avatar size="small">{assignee.name?.charAt(0)?.toUpperCase()}</Avatar>
|
<Avatar size="small">{assignee.name?.charAt(0)?.toUpperCase()}</Avatar>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -158,11 +166,16 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
)}
|
)}
|
||||||
{task.labels && task.labels.length > 0 && (
|
{task.labels && task.labels.length > 0 && (
|
||||||
<div className="kanban-task-labels">
|
<div className="kanban-task-labels">
|
||||||
{task.labels.slice(0, 2).map((label) => (
|
{task.labels.slice(0, 2).map(label => (
|
||||||
<Tag
|
<Tag
|
||||||
key={label.id}
|
key={label.id}
|
||||||
className="kanban-task-label"
|
className="kanban-task-label"
|
||||||
style={{ backgroundColor: label.color_code, border: 'none', color: 'white', marginLeft: 4 }}
|
style={{
|
||||||
|
backgroundColor: label.color_code,
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{label.name}
|
{label.name}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -198,7 +211,7 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
|
|||||||
{/* Subtasks */}
|
{/* Subtasks */}
|
||||||
{task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && (
|
{task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && (
|
||||||
<div className="kanban-task-subtasks">
|
<div className="kanban-task-subtasks">
|
||||||
{task.sub_tasks.map((subtask) => (
|
{task.sub_tasks.map(subtask => (
|
||||||
<KanbanTaskCard
|
<KanbanTaskCard
|
||||||
key={subtask.id}
|
key={subtask.id}
|
||||||
task={subtask}
|
task={subtask}
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragOverEvent,
|
DragOverEvent,
|
||||||
closestCorners,
|
closestCorners,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
horizontalListSortingStrategy,
|
horizontalListSortingStrategy,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { Card, Spin, Empty, Flex } from 'antd';
|
import { Card, Spin, Empty, Flex } from 'antd';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import { IGroupBy, setGroup, fetchTaskGroups, reorderTasks } from '@/features/tasks/tasks.slice';
|
||||||
IGroupBy,
|
|
||||||
setGroup,
|
|
||||||
fetchTaskGroups,
|
|
||||||
reorderTasks,
|
|
||||||
} from '@/features/tasks/tasks.slice';
|
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
import BoardSectionCard from '@/pages/projects/projectView/board/board-section/board-section-card/board-section-card';
|
import BoardSectionCard from '@/pages/projects/projectView/board/board-section/board-section-card/board-section-card';
|
||||||
@@ -38,269 +33,261 @@ import KanbanGroup from './kanbanGroup';
|
|||||||
import KanbanTaskCard from './kanbanTaskCard';
|
import KanbanTaskCard from './kanbanTaskCard';
|
||||||
import SortableKanbanGroup from './SortableKanbanGroup';
|
import SortableKanbanGroup from './SortableKanbanGroup';
|
||||||
|
|
||||||
|
|
||||||
// Import the TaskListFilters component
|
// Import the TaskListFilters component
|
||||||
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
|
const TaskListFilters = React.lazy(
|
||||||
|
() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')
|
||||||
|
);
|
||||||
|
|
||||||
interface TaskListBoardProps {
|
interface TaskListBoardProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
activeTask: IProjectTask | null;
|
activeTask: IProjectTask | null;
|
||||||
activeGroupId: string | null;
|
activeGroupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KanbanTaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
const KanbanTaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const [dragState, setDragState] = useState<DragState>({
|
const [dragState, setDragState] = useState<DragState>({
|
||||||
activeTask: null,
|
activeTask: null,
|
||||||
activeGroupId: null,
|
activeGroupId: null,
|
||||||
});
|
});
|
||||||
// New state for active/over ids
|
// New state for active/over ids
|
||||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||||
const [overId, setOverId] = useState<string | null>(null);
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Redux selectors
|
// Redux selectors
|
||||||
|
|
||||||
const { taskGroups, groupBy, loadingGroups, error, search, archived } = useSelector((state: RootState) => state.boardReducer);
|
const { taskGroups, groupBy, loadingGroups, error, search, archived } = useSelector(
|
||||||
|
(state: RootState) => state.boardReducer
|
||||||
|
);
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// Drag and Drop sensors
|
// Drag and Drop sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8,
|
distance: 8,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
|
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
const isProjectManager = useIsProjectManager();
|
const isProjectManager = useIsProjectManager();
|
||||||
|
|
||||||
// Fetch task groups when component mounts or dependencies change
|
// Fetch task groups when component mounts or dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(fetchTaskGroups(projectId));
|
dispatch(fetchTaskGroups(projectId));
|
||||||
}
|
|
||||||
}, [dispatch, projectId, groupBy, search, archived]);
|
|
||||||
|
|
||||||
// Memoized calculations
|
|
||||||
const allTaskIds = useMemo(() => {
|
|
||||||
return taskGroups.flatMap(group => group.tasks.map(task => task.id!));
|
|
||||||
}, [taskGroups]);
|
|
||||||
|
|
||||||
const totalTasksCount = useMemo(() => {
|
|
||||||
return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0);
|
|
||||||
}, [taskGroups]);
|
|
||||||
|
|
||||||
const hasSelection = selectedTaskIds.length > 0;
|
|
||||||
|
|
||||||
// // Handlers
|
|
||||||
// const handleGroupingChange = (newGroupBy: IGroupBy) => {
|
|
||||||
// dispatch(setGroup(newGroupBy));
|
|
||||||
// };
|
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
|
||||||
const { active } = event;
|
|
||||||
const taskId = active.id as string;
|
|
||||||
setActiveTaskId(taskId);
|
|
||||||
setOverId(null);
|
|
||||||
// Find the task and its group
|
|
||||||
let activeTask: IProjectTask | null = null;
|
|
||||||
let activeGroupId: string | null = null;
|
|
||||||
for (const group of taskGroups) {
|
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
|
||||||
if (task) {
|
|
||||||
activeTask = task;
|
|
||||||
activeGroupId = group.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDragState({
|
|
||||||
activeTask,
|
|
||||||
activeGroupId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragOverEvent) => {
|
|
||||||
setOverId(event.over?.id as string || null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveTaskId(null);
|
|
||||||
setOverId(null);
|
|
||||||
setDragState({
|
|
||||||
activeTask: null,
|
|
||||||
activeGroupId: null,
|
|
||||||
});
|
|
||||||
if (!over || !dragState.activeTask || !dragState.activeGroupId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const activeTaskId = active.id as string;
|
|
||||||
const overIdVal = over.id as string;
|
|
||||||
// Find the group and index for drop
|
|
||||||
let targetGroupId = overIdVal;
|
|
||||||
let targetIndex = -1;
|
|
||||||
let isOverTask = false;
|
|
||||||
// Check if over is a group or a task
|
|
||||||
const overGroup = taskGroups.find(g => g.id === overIdVal);
|
|
||||||
if (!overGroup) {
|
|
||||||
// Dropping on a task, find which group it belongs to
|
|
||||||
for (const group of taskGroups) {
|
|
||||||
const taskIndex = group.tasks.findIndex(t => t.id === overIdVal);
|
|
||||||
if (taskIndex !== -1) {
|
|
||||||
targetGroupId = group.id;
|
|
||||||
targetIndex = taskIndex;
|
|
||||||
isOverTask = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
|
|
||||||
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
|
||||||
if (!sourceGroup || !targetGroup) return;
|
|
||||||
const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
|
||||||
if (sourceIndex === -1) return;
|
|
||||||
// Calculate new positions
|
|
||||||
let finalTargetIndex = targetIndex;
|
|
||||||
if (!isOverTask || finalTargetIndex === -1) {
|
|
||||||
finalTargetIndex = targetGroup.tasks.length;
|
|
||||||
}
|
|
||||||
// If moving within the same group and after itself, adjust index
|
|
||||||
if (sourceGroup.id === targetGroup.id && sourceIndex < finalTargetIndex) {
|
|
||||||
finalTargetIndex--;
|
|
||||||
}
|
|
||||||
// Create updated task arrays
|
|
||||||
const updatedSourceTasks = [...sourceGroup.tasks];
|
|
||||||
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
|
|
||||||
let updatedTargetTasks: IProjectTask[];
|
|
||||||
if (sourceGroup.id === targetGroup.id) {
|
|
||||||
updatedTargetTasks = updatedSourceTasks;
|
|
||||||
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
|
|
||||||
} else {
|
|
||||||
updatedTargetTasks = [...targetGroup.tasks];
|
|
||||||
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
|
|
||||||
}
|
|
||||||
// Dispatch the reorder action
|
|
||||||
dispatch(reorderTasks({
|
|
||||||
activeGroupId: sourceGroup.id,
|
|
||||||
overGroupId: targetGroup.id,
|
|
||||||
fromIndex: sourceIndex,
|
|
||||||
toIndex: finalTargetIndex,
|
|
||||||
task: movedTask,
|
|
||||||
updatedSourceTasks,
|
|
||||||
updatedTargetTasks,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSelectTask = (taskId: string, selected: boolean) => {
|
|
||||||
setSelectedTaskIds(prev => {
|
|
||||||
if (selected) {
|
|
||||||
return [...prev, taskId];
|
|
||||||
} else {
|
|
||||||
return prev.filter(id => id !== taskId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleSubtasks = (taskId: string) => {
|
|
||||||
// Implementation for toggling subtasks
|
|
||||||
console.log('Toggle subtasks for task:', taskId);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<Empty
|
|
||||||
description={`Error loading tasks: ${error}`}
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}, [dispatch, projectId, groupBy, search, archived]);
|
||||||
|
|
||||||
|
// Memoized calculations
|
||||||
|
const allTaskIds = useMemo(() => {
|
||||||
|
return taskGroups.flatMap(group => group.tasks.map(task => task.id!));
|
||||||
|
}, [taskGroups]);
|
||||||
|
|
||||||
|
const totalTasksCount = useMemo(() => {
|
||||||
|
return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0);
|
||||||
|
}, [taskGroups]);
|
||||||
|
|
||||||
|
const hasSelection = selectedTaskIds.length > 0;
|
||||||
|
|
||||||
|
// // Handlers
|
||||||
|
// const handleGroupingChange = (newGroupBy: IGroupBy) => {
|
||||||
|
// dispatch(setGroup(newGroupBy));
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
const { active } = event;
|
||||||
|
const taskId = active.id as string;
|
||||||
|
setActiveTaskId(taskId);
|
||||||
|
setOverId(null);
|
||||||
|
// Find the task and its group
|
||||||
|
let activeTask: IProjectTask | null = null;
|
||||||
|
let activeGroupId: string | null = null;
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const task = group.tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
activeTask = task;
|
||||||
|
activeGroupId = group.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDragState({
|
||||||
|
activeTask,
|
||||||
|
activeGroupId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragOverEvent) => {
|
||||||
|
setOverId((event.over?.id as string) || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveTaskId(null);
|
||||||
|
setOverId(null);
|
||||||
|
setDragState({
|
||||||
|
activeTask: null,
|
||||||
|
activeGroupId: null,
|
||||||
|
});
|
||||||
|
if (!over || !dragState.activeTask || !dragState.activeGroupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeTaskId = active.id as string;
|
||||||
|
const overIdVal = over.id as string;
|
||||||
|
// Find the group and index for drop
|
||||||
|
let targetGroupId = overIdVal;
|
||||||
|
let targetIndex = -1;
|
||||||
|
let isOverTask = false;
|
||||||
|
// Check if over is a group or a task
|
||||||
|
const overGroup = taskGroups.find(g => g.id === overIdVal);
|
||||||
|
if (!overGroup) {
|
||||||
|
// Dropping on a task, find which group it belongs to
|
||||||
|
for (const group of taskGroups) {
|
||||||
|
const taskIndex = group.tasks.findIndex(t => t.id === overIdVal);
|
||||||
|
if (taskIndex !== -1) {
|
||||||
|
targetGroupId = group.id;
|
||||||
|
targetIndex = taskIndex;
|
||||||
|
isOverTask = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
if (!sourceGroup || !targetGroup) return;
|
||||||
|
const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
||||||
|
if (sourceIndex === -1) return;
|
||||||
|
// Calculate new positions
|
||||||
|
let finalTargetIndex = targetIndex;
|
||||||
|
if (!isOverTask || finalTargetIndex === -1) {
|
||||||
|
finalTargetIndex = targetGroup.tasks.length;
|
||||||
|
}
|
||||||
|
// If moving within the same group and after itself, adjust index
|
||||||
|
if (sourceGroup.id === targetGroup.id && sourceIndex < finalTargetIndex) {
|
||||||
|
finalTargetIndex--;
|
||||||
|
}
|
||||||
|
// Create updated task arrays
|
||||||
|
const updatedSourceTasks = [...sourceGroup.tasks];
|
||||||
|
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
|
||||||
|
let updatedTargetTasks: IProjectTask[];
|
||||||
|
if (sourceGroup.id === targetGroup.id) {
|
||||||
|
updatedTargetTasks = updatedSourceTasks;
|
||||||
|
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
|
||||||
|
} else {
|
||||||
|
updatedTargetTasks = [...targetGroup.tasks];
|
||||||
|
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
|
||||||
|
}
|
||||||
|
// Dispatch the reorder action
|
||||||
|
dispatch(
|
||||||
|
reorderTasks({
|
||||||
|
activeGroupId: sourceGroup.id,
|
||||||
|
overGroupId: targetGroup.id,
|
||||||
|
fromIndex: sourceIndex,
|
||||||
|
toIndex: finalTargetIndex,
|
||||||
|
task: movedTask,
|
||||||
|
updatedSourceTasks,
|
||||||
|
updatedTargetTasks,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectTask = (taskId: string, selected: boolean) => {
|
||||||
|
setSelectedTaskIds(prev => {
|
||||||
|
if (selected) {
|
||||||
|
return [...prev, taskId];
|
||||||
|
} else {
|
||||||
|
return prev.filter(id => id !== taskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleSubtasks = (taskId: string) => {
|
||||||
|
// Implementation for toggling subtasks
|
||||||
|
console.log('Toggle subtasks for task:', taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={`task-list-board ${className}`}>
|
<Card className={className}>
|
||||||
{/* Task Filters */}
|
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
<Card
|
</Card>
|
||||||
size="small"
|
);
|
||||||
className="mb-4"
|
}
|
||||||
styles={{ body: { padding: '12px 16px' } }}
|
|
||||||
>
|
|
||||||
<React.Suspense fallback={<div>Loading filters...</div>}>
|
|
||||||
<TaskListFilters position="board" />
|
|
||||||
</React.Suspense>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`task-list-board ${className}`}>
|
||||||
|
{/* Task Filters */}
|
||||||
|
<Card size="small" className="mb-4" styles={{ body: { padding: '12px 16px' } }}>
|
||||||
|
<React.Suspense fallback={<div>Loading filters...</div>}>
|
||||||
|
<TaskListFilters position="board" />
|
||||||
|
</React.Suspense>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Task Groups Container */}
|
{/* Task Groups Container */}
|
||||||
<div className="task-groups-outer-container">
|
<div className="task-groups-outer-container">
|
||||||
{loadingGroups ? (
|
{loadingGroups ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : taskGroups.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<Empty
|
|
||||||
description="No tasks found"
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCorners}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={taskGroups.map(g => g.id)}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="task-groups-container">
|
|
||||||
{taskGroups.map((group) => (
|
|
||||||
<SortableKanbanGroup
|
|
||||||
key={group.id}
|
|
||||||
group={group}
|
|
||||||
projectId={projectId}
|
|
||||||
currentGrouping={groupBy}
|
|
||||||
selectedTaskIds={selectedTaskIds}
|
|
||||||
onSelectTask={handleSelectTask}
|
|
||||||
onToggleSubtasks={handleToggleSubtasks}
|
|
||||||
activeTaskId={activeTaskId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
<DragOverlay>
|
|
||||||
{dragState.activeTask ? (
|
|
||||||
<KanbanTaskCard
|
|
||||||
task={dragState.activeTask}
|
|
||||||
projectId={projectId}
|
|
||||||
groupId={dragState.activeGroupId!}
|
|
||||||
currentGrouping={groupBy}
|
|
||||||
isSelected={false}
|
|
||||||
isDragOverlay
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : taskGroups.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={taskGroups.map(g => g.id)}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="task-groups-container">
|
||||||
|
{taskGroups.map(group => (
|
||||||
|
<SortableKanbanGroup
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
projectId={projectId}
|
||||||
|
currentGrouping={groupBy}
|
||||||
|
selectedTaskIds={selectedTaskIds}
|
||||||
|
onSelectTask={handleSelectTask}
|
||||||
|
onToggleSubtasks={handleToggleSubtasks}
|
||||||
|
activeTaskId={activeTaskId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
<DragOverlay>
|
||||||
|
{dragState.activeTask ? (
|
||||||
|
<KanbanTaskCard
|
||||||
|
task={dragState.activeTask}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={dragState.activeGroupId!}
|
||||||
|
currentGrouping={groupBy}
|
||||||
|
isSelected={false}
|
||||||
|
isDragOverlay
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.task-groups-outer-container {
|
.task-groups-outer-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -405,8 +392,8 @@ const KanbanTaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, classNam
|
|||||||
--task-drag-over-border: #40a9ff;
|
--task-drag-over-border: #40a9ff;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default KanbanTaskListBoard;
|
export default KanbanTaskListBoard;
|
||||||
@@ -83,7 +83,10 @@ const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotificat
|
|||||||
You have been invited to work with <b>{item.team_name}</b>.
|
You have been invited to work with <b>{item.team_name}</b>.
|
||||||
</div>
|
</div>
|
||||||
{isUnreadNotifications && (
|
{isUnreadNotifications && (
|
||||||
<div className="mt-2" style={{ display: 'flex', gap: '8px', justifyContent: 'space-between' }}>
|
<div
|
||||||
|
className="mt-2"
|
||||||
|
style={{ display: 'flex', gap: '8px', justifyContent: 'space-between' }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => acceptInvite(true)}
|
onClick={() => acceptInvite(true)}
|
||||||
disabled={inProgress()}
|
disabled={inProgress()}
|
||||||
|
|||||||
@@ -164,15 +164,15 @@ const NotificationDrawer = () => {
|
|||||||
await handleVerifyAuth();
|
await handleVerifyAuth();
|
||||||
}
|
}
|
||||||
if (notification.project && notification.task_id) {
|
if (notification.project && notification.task_id) {
|
||||||
navigate(`${notification.url}${toQueryString({task: notification.params?.task, tab: notification.params?.tab})}`);
|
navigate(
|
||||||
|
`${notification.url}${toQueryString({ task: notification.params?.task, tab: notification.params?.tab })}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error navigating to URL:', error);
|
console.error('Error navigating to URL:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { toQueryString } from '@/utils/toQueryString';
|
|||||||
import { BankOutlined } from '@ant-design/icons';
|
import { BankOutlined } from '@ant-design/icons';
|
||||||
import './push-notification-template.css';
|
import './push-notification-template.css';
|
||||||
|
|
||||||
const PushNotificationTemplate = ({ notification: notificationData }: { notification: IWorklenzNotification }) => {
|
const PushNotificationTemplate = ({
|
||||||
|
notification: notificationData,
|
||||||
|
}: {
|
||||||
|
notification: IWorklenzNotification;
|
||||||
|
}) => {
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
if (notificationData.url) {
|
if (notificationData.url) {
|
||||||
let url = notificationData.url;
|
let url = notificationData.url;
|
||||||
@@ -29,17 +33,19 @@ const PushNotificationTemplate = ({ notification: notificationData }: { notifica
|
|||||||
style={{
|
style={{
|
||||||
cursor: notificationData.url ? 'pointer' : 'default',
|
cursor: notificationData.url ? 'pointer' : 'default',
|
||||||
padding: '8px 0',
|
padding: '8px 0',
|
||||||
borderRadius: '8px'
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex',
|
style={{
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
marginBottom: '8px',
|
alignItems: 'center',
|
||||||
color: '#262626',
|
marginBottom: '8px',
|
||||||
fontSize: '14px',
|
color: '#262626',
|
||||||
fontWeight: 500
|
fontSize: '14px',
|
||||||
}}>
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{notificationData.team && (
|
{notificationData.team && (
|
||||||
<>
|
<>
|
||||||
<BankOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
|
<BankOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
|
||||||
@@ -53,7 +59,7 @@ const PushNotificationTemplate = ({ notification: notificationData }: { notifica
|
|||||||
color: '#595959',
|
color: '#595959',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
marginTop: '4px'
|
marginTop: '4px',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: notificationData.message }}
|
dangerouslySetInnerHTML={{ __html: notificationData.message }}
|
||||||
/>
|
/>
|
||||||
@@ -81,12 +87,12 @@ const processNotificationQueue = () => {
|
|||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
minWidth: '300px',
|
minWidth: '300px',
|
||||||
maxWidth: '400px'
|
maxWidth: '400px',
|
||||||
},
|
},
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
processNotificationQueue();
|
processNotificationQueue();
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Avatar,
|
Avatar,
|
||||||
theme,
|
theme,
|
||||||
Divider
|
Divider,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
MoreOutlined
|
MoreOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { ProjectGroupListProps } from '@/types/project/project.types';
|
import { ProjectGroupListProps } from '@/types/project/project.types';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
@@ -31,18 +31,18 @@ import { themeWiseColor } from '@/utils/themeWiseColor';
|
|||||||
import {
|
import {
|
||||||
fetchProjectData,
|
fetchProjectData,
|
||||||
setProjectId,
|
setProjectId,
|
||||||
toggleProjectDrawer
|
toggleProjectDrawer,
|
||||||
} from '@/features/project/project-drawer.slice';
|
} from '@/features/project/project-drawer.slice';
|
||||||
import {
|
import {
|
||||||
toggleArchiveProject,
|
toggleArchiveProject,
|
||||||
toggleArchiveProjectForAll
|
toggleArchiveProjectForAll,
|
||||||
} from '@/features/projects/projectsSlice';
|
} from '@/features/projects/projectsSlice';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
import {
|
import {
|
||||||
evt_projects_settings_click,
|
evt_projects_settings_click,
|
||||||
evt_projects_archive,
|
evt_projects_archive,
|
||||||
evt_projects_archive_all
|
evt_projects_archive_all,
|
||||||
} from '@/shared/worklenz-analytics-events';
|
} from '@/shared/worklenz-analytics-events';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
navigate,
|
navigate,
|
||||||
onProjectSelect,
|
onProjectSelect,
|
||||||
loading,
|
loading,
|
||||||
t
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
// Preload project view components on hover for smoother navigation
|
// Preload project view components on hover for smoother navigation
|
||||||
const handleProjectHover = React.useCallback((project_id: string) => {
|
const handleProjectHover = React.useCallback((project_id: string) => {
|
||||||
@@ -130,7 +130,11 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
dispatch(toggleProjectDrawer());
|
dispatch(toggleProjectDrawer());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchiveClick = async (e: React.MouseEvent, projectId: string, isArchived: boolean) => {
|
const handleArchiveClick = async (
|
||||||
|
e: React.MouseEvent,
|
||||||
|
projectId: string,
|
||||||
|
isArchived: boolean
|
||||||
|
) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
if (isOwnerOrAdmin) {
|
if (isOwnerOrAdmin) {
|
||||||
@@ -146,184 +150,187 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Memoized styles for better performance
|
// Memoized styles for better performance
|
||||||
const styles = useMemo(() => ({
|
const styles = useMemo(
|
||||||
container: {
|
() => ({
|
||||||
padding: '0',
|
container: {
|
||||||
background: 'transparent',
|
padding: '0',
|
||||||
},
|
background: 'transparent',
|
||||||
groupSection: {
|
},
|
||||||
marginBottom: '24px',
|
groupSection: {
|
||||||
background: 'transparent',
|
marginBottom: '24px',
|
||||||
},
|
background: 'transparent',
|
||||||
groupHeader: {
|
},
|
||||||
background: getThemeAwareColor(
|
groupHeader: {
|
||||||
`linear-gradient(135deg, ${token.colorFillAlter} 0%, ${token.colorFillTertiary} 100%)`,
|
background: getThemeAwareColor(
|
||||||
`linear-gradient(135deg, ${token.colorFillQuaternary} 0%, ${token.colorFillSecondary} 100%)`
|
`linear-gradient(135deg, ${token.colorFillAlter} 0%, ${token.colorFillTertiary} 100%)`,
|
||||||
),
|
`linear-gradient(135deg, ${token.colorFillQuaternary} 0%, ${token.colorFillSecondary} 100%)`
|
||||||
borderRadius: token.borderRadius,
|
),
|
||||||
padding: '12px 16px',
|
borderRadius: token.borderRadius,
|
||||||
marginBottom: '12px',
|
padding: '12px 16px',
|
||||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
marginBottom: '12px',
|
||||||
boxShadow: getThemeAwareColor(
|
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.06)',
|
boxShadow: getThemeAwareColor(
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.15)'
|
'0 1px 4px rgba(0, 0, 0, 0.06)',
|
||||||
),
|
'0 1px 4px rgba(0, 0, 0, 0.15)'
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
),
|
||||||
},
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
groupTitle: {
|
},
|
||||||
margin: 0,
|
groupTitle: {
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
margin: 0,
|
||||||
fontSize: '16px',
|
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||||
fontWeight: 600,
|
fontSize: '16px',
|
||||||
letterSpacing: '-0.01em',
|
fontWeight: 600,
|
||||||
},
|
letterSpacing: '-0.01em',
|
||||||
groupMeta: {
|
},
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
groupMeta: {
|
||||||
fontSize: '12px',
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
marginTop: '2px',
|
fontSize: '12px',
|
||||||
},
|
marginTop: '2px',
|
||||||
projectCard: {
|
},
|
||||||
height: '100%',
|
projectCard: {
|
||||||
borderRadius: token.borderRadius,
|
height: '100%',
|
||||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
borderRadius: token.borderRadius,
|
||||||
boxShadow: getThemeAwareColor(
|
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.04)',
|
boxShadow: getThemeAwareColor(
|
||||||
'0 1px 4px rgba(0, 0, 0, 0.12)'
|
'0 1px 4px rgba(0, 0, 0, 0.04)',
|
||||||
),
|
'0 1px 4px rgba(0, 0, 0, 0.12)'
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
),
|
||||||
cursor: 'pointer',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
overflow: 'hidden',
|
cursor: 'pointer',
|
||||||
background: getThemeAwareColor(token.colorBgContainer, token.colorBgElevated),
|
overflow: 'hidden',
|
||||||
},
|
background: getThemeAwareColor(token.colorBgContainer, token.colorBgElevated),
|
||||||
projectCardHover: {
|
},
|
||||||
transform: 'translateY(-2px)',
|
projectCardHover: {
|
||||||
boxShadow: getThemeAwareColor(
|
transform: 'translateY(-2px)',
|
||||||
'0 4px 12px rgba(0, 0, 0, 0.08)',
|
boxShadow: getThemeAwareColor(
|
||||||
'0 4px 12px rgba(0, 0, 0, 0.20)'
|
'0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||||
),
|
'0 4px 12px rgba(0, 0, 0, 0.20)'
|
||||||
borderColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
),
|
||||||
},
|
borderColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
statusBar: {
|
},
|
||||||
height: '3px',
|
statusBar: {
|
||||||
background: 'linear-gradient(90deg, transparent 0%, currentColor 100%)',
|
height: '3px',
|
||||||
borderRadius: '0 0 2px 2px',
|
background: 'linear-gradient(90deg, transparent 0%, currentColor 100%)',
|
||||||
},
|
borderRadius: '0 0 2px 2px',
|
||||||
projectContent: {
|
},
|
||||||
padding: '12px',
|
projectContent: {
|
||||||
height: '100%',
|
padding: '12px',
|
||||||
display: 'flex',
|
height: '100%',
|
||||||
flexDirection: 'column' as const,
|
display: 'flex',
|
||||||
minHeight: '200px', // Ensure minimum height for consistent card sizes
|
flexDirection: 'column' as const,
|
||||||
},
|
minHeight: '200px', // Ensure minimum height for consistent card sizes
|
||||||
projectTitle: {
|
},
|
||||||
margin: '0 0 6px 0',
|
projectTitle: {
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
margin: '0 0 6px 0',
|
||||||
fontSize: '14px',
|
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||||
fontWeight: 600,
|
fontSize: '14px',
|
||||||
lineHeight: 1.3,
|
fontWeight: 600,
|
||||||
},
|
lineHeight: 1.3,
|
||||||
clientName: {
|
},
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
clientName: {
|
||||||
fontSize: '12px',
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
marginBottom: '8px',
|
fontSize: '12px',
|
||||||
display: 'flex',
|
marginBottom: '8px',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
gap: '4px',
|
alignItems: 'center',
|
||||||
},
|
gap: '4px',
|
||||||
progressSection: {
|
},
|
||||||
marginBottom: '10px',
|
progressSection: {
|
||||||
// Remove flex: 1 to prevent it from taking all available space
|
marginBottom: '10px',
|
||||||
},
|
// Remove flex: 1 to prevent it from taking all available space
|
||||||
progressLabel: {
|
},
|
||||||
fontSize: '10px',
|
progressLabel: {
|
||||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
fontSize: '10px',
|
||||||
marginBottom: '4px',
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||||
fontWeight: 500,
|
marginBottom: '4px',
|
||||||
textTransform: 'uppercase' as const,
|
fontWeight: 500,
|
||||||
letterSpacing: '0.3px',
|
textTransform: 'uppercase' as const,
|
||||||
},
|
letterSpacing: '0.3px',
|
||||||
metaGrid: {
|
},
|
||||||
display: 'grid',
|
metaGrid: {
|
||||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
display: 'grid',
|
||||||
gap: '8px',
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
marginTop: 'auto', // This pushes the meta section to the bottom
|
gap: '8px',
|
||||||
paddingTop: '10px',
|
marginTop: 'auto', // This pushes the meta section to the bottom
|
||||||
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
paddingTop: '10px',
|
||||||
flexShrink: 0, // Prevent the meta section from shrinking
|
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
},
|
flexShrink: 0, // Prevent the meta section from shrinking
|
||||||
metaItem: {
|
},
|
||||||
display: 'flex',
|
metaItem: {
|
||||||
flexDirection: 'row' as const,
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'row' as const,
|
||||||
gap: '8px',
|
alignItems: 'center',
|
||||||
padding: '8px 12px',
|
gap: '8px',
|
||||||
borderRadius: token.borderRadiusSM,
|
padding: '8px 12px',
|
||||||
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
borderRadius: token.borderRadiusSM,
|
||||||
transition: 'all 0.2s ease',
|
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
||||||
},
|
transition: 'all 0.2s ease',
|
||||||
metaContent: {
|
},
|
||||||
display: 'flex',
|
metaContent: {
|
||||||
flexDirection: 'column' as const,
|
display: 'flex',
|
||||||
gap: '1px',
|
flexDirection: 'column' as const,
|
||||||
flex: 1,
|
gap: '1px',
|
||||||
},
|
flex: 1,
|
||||||
metaIcon: {
|
},
|
||||||
fontSize: '12px',
|
metaIcon: {
|
||||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
fontSize: '12px',
|
||||||
},
|
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
metaValue: {
|
},
|
||||||
fontSize: '11px',
|
metaValue: {
|
||||||
fontWeight: 600,
|
fontSize: '11px',
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
fontWeight: 600,
|
||||||
lineHeight: 1,
|
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
||||||
},
|
lineHeight: 1,
|
||||||
metaLabel: {
|
},
|
||||||
fontSize: '9px',
|
metaLabel: {
|
||||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
fontSize: '9px',
|
||||||
lineHeight: 1,
|
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
||||||
textTransform: 'uppercase' as const,
|
lineHeight: 1,
|
||||||
letterSpacing: '0.2px',
|
textTransform: 'uppercase' as const,
|
||||||
},
|
letterSpacing: '0.2px',
|
||||||
actionButtons: {
|
},
|
||||||
position: 'absolute' as const,
|
actionButtons: {
|
||||||
top: '8px',
|
position: 'absolute' as const,
|
||||||
right: '8px',
|
top: '8px',
|
||||||
display: 'flex',
|
right: '8px',
|
||||||
gap: '4px',
|
display: 'flex',
|
||||||
opacity: 0,
|
gap: '4px',
|
||||||
transition: 'opacity 0.2s ease',
|
opacity: 0,
|
||||||
},
|
transition: 'opacity 0.2s ease',
|
||||||
actionButton: {
|
},
|
||||||
width: '24px',
|
actionButton: {
|
||||||
height: '24px',
|
width: '24px',
|
||||||
borderRadius: '4px',
|
height: '24px',
|
||||||
border: 'none',
|
borderRadius: '4px',
|
||||||
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
border: 'none',
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
||||||
cursor: 'pointer',
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
display: 'flex',
|
cursor: 'pointer',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
fontSize: '12px',
|
justifyContent: 'center',
|
||||||
transition: 'all 0.2s ease',
|
fontSize: '12px',
|
||||||
backdropFilter: 'blur(4px)',
|
transition: 'all 0.2s ease',
|
||||||
'&:hover': {
|
backdropFilter: 'blur(4px)',
|
||||||
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
'&:hover': {
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
transform: 'scale(1.1)',
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||||
}
|
transform: 'scale(1.1)',
|
||||||
},
|
},
|
||||||
emptyState: {
|
},
|
||||||
padding: '60px 20px',
|
emptyState: {
|
||||||
textAlign: 'center' as const,
|
padding: '60px 20px',
|
||||||
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
textAlign: 'center' as const,
|
||||||
borderRadius: token.borderRadiusLG,
|
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
|
||||||
border: `2px dashed ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
borderRadius: token.borderRadiusLG,
|
||||||
},
|
border: `2px dashed ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
||||||
loadingContainer: {
|
},
|
||||||
padding: '40px 20px',
|
loadingContainer: {
|
||||||
}
|
padding: '40px 20px',
|
||||||
}), [token, themeMode, getThemeAwareColor]);
|
},
|
||||||
|
}),
|
||||||
|
[token, themeMode, getThemeAwareColor]
|
||||||
|
);
|
||||||
|
|
||||||
// Early return for loading state
|
// Early return for loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -356,19 +363,19 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderProjectCard = (project: any) => {
|
const renderProjectCard = (project: any) => {
|
||||||
const projectColor = processColor(project.color_code, token.colorPrimary);
|
const projectColor = processColor(project.color_code, token.colorPrimary);
|
||||||
const statusColor = processColor(project.status_color, token.colorPrimary);
|
const statusColor = processColor(project.status_color, token.colorPrimary);
|
||||||
const progress = project.progress || 0;
|
const progress = project.progress || 0;
|
||||||
const completedTasks = project.completed_tasks_count || 0;
|
const completedTasks = project.completed_tasks_count || 0;
|
||||||
const totalTasks = project.all_tasks_count || 0;
|
const totalTasks = project.all_tasks_count || 0;
|
||||||
const membersCount = project.members_count || 0;
|
const membersCount = project.members_count || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col key={project.id} xs={24} sm={12} md={8} lg={6} xl={4}>
|
<Col key={project.id} xs={24} sm={12} md={8} lg={6} xl={4}>
|
||||||
<Card
|
<Card
|
||||||
style={{ ...styles.projectCard, position: 'relative' }}
|
style={{ ...styles.projectCard, position: 'relative' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={e => {
|
||||||
Object.assign(e.currentTarget.style, styles.projectCardHover);
|
Object.assign(e.currentTarget.style, styles.projectCardHover);
|
||||||
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
||||||
if (actionButtons) {
|
if (actionButtons) {
|
||||||
@@ -377,7 +384,7 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
// Preload components for smoother navigation
|
// Preload components for smoother navigation
|
||||||
handleProjectHover(project.id);
|
handleProjectHover(project.id);
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={e => {
|
||||||
Object.assign(e.currentTarget.style, styles.projectCard);
|
Object.assign(e.currentTarget.style, styles.projectCard);
|
||||||
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
|
||||||
if (actionButtons) {
|
if (actionButtons) {
|
||||||
@@ -392,15 +399,15 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
<Tooltip title={t('setting')}>
|
<Tooltip title={t('setting')}>
|
||||||
<button
|
<button
|
||||||
style={styles.actionButton}
|
style={styles.actionButton}
|
||||||
onClick={(e) => handleSettingsClick(e, project.id)}
|
onClick={e => handleSettingsClick(e, project.id)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={e => {
|
||||||
Object.assign(e.currentTarget.style, {
|
Object.assign(e.currentTarget.style, {
|
||||||
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||||
transform: 'scale(1.1)',
|
transform: 'scale(1.1)',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={e => {
|
||||||
Object.assign(e.currentTarget.style, {
|
Object.assign(e.currentTarget.style, {
|
||||||
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
@@ -414,15 +421,15 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
<Tooltip title={project.archived ? t('unarchive') : t('archive')}>
|
<Tooltip title={project.archived ? t('unarchive') : t('archive')}>
|
||||||
<button
|
<button
|
||||||
style={styles.actionButton}
|
style={styles.actionButton}
|
||||||
onClick={(e) => handleArchiveClick(e, project.id, project.archived)}
|
onClick={e => handleArchiveClick(e, project.id, project.archived)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={e => {
|
||||||
Object.assign(e.currentTarget.style, {
|
Object.assign(e.currentTarget.style, {
|
||||||
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
||||||
transform: 'scale(1.1)',
|
transform: 'scale(1.1)',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={e => {
|
||||||
Object.assign(e.currentTarget.style, {
|
Object.assign(e.currentTarget.style, {
|
||||||
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
@@ -434,17 +441,21 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{/* Project color indicator bar */}
|
{/* Project color indicator bar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...styles.statusBar,
|
...styles.statusBar,
|
||||||
color: projectColor,
|
color: projectColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={styles.projectContent}>
|
<div style={styles.projectContent}>
|
||||||
{/* Project title */}
|
{/* Project title */}
|
||||||
<Title level={5} ellipsis={{ rows: 2, tooltip: project.name }} style={styles.projectTitle}>
|
<Title
|
||||||
|
level={5}
|
||||||
|
ellipsis={{ rows: 2, tooltip: project.name }}
|
||||||
|
style={styles.projectTitle}
|
||||||
|
>
|
||||||
{project.name}
|
{project.name}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
@@ -460,28 +471,28 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
|
|
||||||
{/* Progress section */}
|
{/* Progress section */}
|
||||||
<div style={styles.progressSection}>
|
<div style={styles.progressSection}>
|
||||||
<div style={styles.progressLabel}>
|
<div style={styles.progressLabel}>Progress</div>
|
||||||
Progress
|
<Progress
|
||||||
</div>
|
percent={progress}
|
||||||
<Progress
|
size="small"
|
||||||
percent={progress}
|
strokeColor={{
|
||||||
size="small"
|
'0%': projectColor,
|
||||||
strokeColor={{
|
'100%': statusColor,
|
||||||
'0%': projectColor,
|
}}
|
||||||
'100%': statusColor,
|
trailColor={getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)}
|
||||||
}}
|
strokeWidth={4}
|
||||||
trailColor={getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)}
|
showInfo={false}
|
||||||
strokeWidth={4}
|
/>
|
||||||
showInfo={false}
|
<Text
|
||||||
/>
|
style={{
|
||||||
<Text style={{
|
fontSize: '10px',
|
||||||
fontSize: '10px',
|
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
marginTop: '2px',
|
||||||
marginTop: '2px',
|
display: 'block',
|
||||||
display: 'block'
|
}}
|
||||||
}}>
|
>
|
||||||
{progress}%
|
{progress}%
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Meta information grid */}
|
{/* Meta information grid */}
|
||||||
@@ -490,7 +501,9 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
<div style={styles.metaItem}>
|
<div style={styles.metaItem}>
|
||||||
<CheckCircleOutlined style={styles.metaIcon} />
|
<CheckCircleOutlined style={styles.metaIcon} />
|
||||||
<div style={styles.metaContent}>
|
<div style={styles.metaContent}>
|
||||||
<span style={styles.metaValue}>{completedTasks}/{totalTasks}</span>
|
<span style={styles.metaValue}>
|
||||||
|
{completedTasks}/{totalTasks}
|
||||||
|
</span>
|
||||||
<span style={styles.metaLabel}>Tasks</span>
|
<span style={styles.metaLabel}>Tasks</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -521,14 +534,16 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Space align="center">
|
<Space align="center">
|
||||||
{group.groupColor && (
|
{group.groupColor && (
|
||||||
<div style={{
|
<div
|
||||||
width: '16px',
|
style={{
|
||||||
height: '16px',
|
width: '16px',
|
||||||
borderRadius: '50%',
|
height: '16px',
|
||||||
backgroundColor: processColor(group.groupColor),
|
borderRadius: '50%',
|
||||||
flexShrink: 0,
|
backgroundColor: processColor(group.groupColor),
|
||||||
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`
|
flexShrink: 0,
|
||||||
}} />
|
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Title level={4} style={styles.groupTitle}>
|
<Title level={4} style={styles.groupTitle}>
|
||||||
@@ -551,24 +566,24 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
|
|||||||
height: '24px',
|
height: '24px',
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
border: `2px solid ${getThemeAwareColor(token.colorBgContainer, token.colorBgElevated)}`
|
border: `2px solid ${getThemeAwareColor(token.colorBgContainer, token.colorBgElevated)}`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects grid */}
|
{/* Projects grid */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>{group.projects.map(renderProjectCard)}</Row>
|
||||||
{group.projects.map(renderProjectCard)}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* Add spacing between groups except for the last one */}
|
{/* Add spacing between groups except for the last one */}
|
||||||
{groupIndex < groups.length - 1 && (
|
{groupIndex < groups.length - 1 && (
|
||||||
<Divider style={{
|
<Divider
|
||||||
margin: '32px 0 0 0',
|
style={{
|
||||||
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
|
margin: '32px 0 0 0',
|
||||||
opacity: 0.5
|
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
|
||||||
}} />
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
import { fetchProjectData, setProjectId, toggleProjectDrawer } from '@/features/project/project-drawer.slice';
|
import {
|
||||||
|
fetchProjectData,
|
||||||
|
setProjectId,
|
||||||
|
toggleProjectDrawer,
|
||||||
|
} from '@/features/project/project-drawer.slice';
|
||||||
import {
|
import {
|
||||||
toggleArchiveProjectForAll,
|
toggleArchiveProjectForAll,
|
||||||
toggleArchiveProject,
|
toggleArchiveProject,
|
||||||
@@ -12,7 +16,11 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
|||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { SettingOutlined, InboxOutlined } from '@ant-design/icons';
|
import { SettingOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
import { Tooltip, Button, Popconfirm, Space } from 'antd';
|
import { Tooltip, Button, Popconfirm, Space } from 'antd';
|
||||||
import { evt_projects_archive, evt_projects_archive_all, evt_projects_settings_click } from '@/shared/worklenz-analytics-events';
|
import {
|
||||||
|
evt_projects_archive,
|
||||||
|
evt_projects_archive_all,
|
||||||
|
evt_projects_settings_click,
|
||||||
|
} from '@/shared/worklenz-analytics-events';
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
|
||||||
interface ActionButtonsProps {
|
interface ActionButtonsProps {
|
||||||
@@ -71,7 +79,9 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
|||||||
icon={<SettingOutlined />}
|
icon={<SettingOutlined />}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={isEditable ? (record.archived ? t('unarchive') : t('archive')) : t('noPermission')}>
|
<Tooltip
|
||||||
|
title={isEditable ? (record.archived ? t('unarchive') : t('archive')) : t('noPermission')}
|
||||||
|
>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={record.archived ? t('unarchive') : t('archive')}
|
title={record.archived ? t('unarchive') : t('archive')}
|
||||||
description={record.archived ? t('unarchiveConfirm') : t('archiveConfirm')}
|
description={record.archived ? t('unarchiveConfirm') : t('archiveConfirm')}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user