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:
Chamika J
2025-07-03 09:46:09 +05:30
committed by GitHub
436 changed files with 13653 additions and 11109 deletions

View File

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

View File

@@ -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:"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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."
} }

View File

@@ -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"
} }

View File

@@ -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:"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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."
} }

View File

@@ -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"
} }

View File

@@ -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:"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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."
} }

View File

@@ -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"
} }

View File

@@ -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();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
},
};

View File

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

View File

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

View File

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

View File

@@ -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() : ''}`

View File

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

View File

@@ -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');
} }

View File

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

View File

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

View File

@@ -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>
),
})), })),
}, },
]; ];

View File

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

View File

@@ -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
)} )}
</> </>
); );
}; };

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(', ')}>

View File

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

View File

@@ -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
)} )}
</> </>
); );
}; };

View File

@@ -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 && (

View File

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

View File

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

View File

@@ -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>) => {

View File

@@ -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(
() => () =>

View File

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

View File

@@ -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}
&nbsp;{t('perMonthPerUser')} &nbsp;{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>

View File

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

View File

@@ -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}
/> />

View File

@@ -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
)} )}

View File

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

View File

@@ -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}&nbsp; {member.email}&nbsp;
{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={{

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()}

View File

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

View File

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

View File

@@ -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>
))} ))}

View File

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