expand sub tasks

This commit is contained in:
chamiakJ
2025-07-03 01:31:05 +05:30
parent 3bef18901a
commit ecd4d29a38
435 changed files with 13150 additions and 11087 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

@@ -14,4 +14,4 @@
/* Maintain hover state */ /* Maintain hover state */
.table-body-row:hover .sticky-column { .table-body-row:hover .sticky-column {
background-color: var(--background-hover); background-color: var(--background-hover);
} }

View File

@@ -4,4 +4,4 @@
// Set undefined values so the application falls back to build-time env vars // Set undefined values so the application falls back to build-time env vars
window.VITE_API_URL = undefined; window.VITE_API_URL = undefined;
window.VITE_SOCKET_URL = undefined; window.VITE_SOCKET_URL = undefined;

View File

@@ -12,4 +12,4 @@
"enterSubtaskName": "Shkruani emrin e nën-detyrës...", "enterSubtaskName": "Shkruani emrin e nën-detyrës...",
"add": "Shto", "add": "Shto",
"cancel": "Anulo" "cancel": "Anulo"
} }

View File

@@ -26,4 +26,4 @@
"add-sub-task": "+ Unteraufgabe hinzufügen", "add-sub-task": "+ Unteraufgabe hinzufügen",
"refresh-sub-tasks": "Unteraufgaben aktualisieren" "refresh-sub-tasks": "Unteraufgaben aktualisieren"
} }
} }

View File

@@ -12,4 +12,4 @@
"enterSubtaskName": "Unteraufgabenname eingeben...", "enterSubtaskName": "Unteraufgabenname eingeben...",
"add": "Hinzufügen", "add": "Hinzufügen",
"cancel": "Abbrechen" "cancel": "Abbrechen"
} }

View File

@@ -25,7 +25,7 @@
"paymentMethod": "Payment Method", "paymentMethod": "Payment Method",
"status": "Status", "status": "Status",
"ltdUsers": "You can add up to {{ltd_users}} users.", "ltdUsers": "You can add up to {{ltd_users}} users.",
"totalSeats": "Total seats", "totalSeats": "Total seats",
"availableSeats": "Available seats", "availableSeats": "Available seats",
"addMoreSeats": "Add more seats", "addMoreSeats": "Add more seats",
@@ -103,11 +103,11 @@
"perMonthPerUser": "per user/month", "perMonthPerUser": "per user/month",
"viewInvoice": "View Invoice", "viewInvoice": "View Invoice",
"switchToFreePlan": "Switch to Free Plan", "switchToFreePlan": "Switch to Free Plan",
"expirestoday": "today", "expirestoday": "today",
"expirestomorrow": "tomorrow", "expirestomorrow": "tomorrow",
"expiredDaysAgo": "{{days}} days ago", "expiredDaysAgo": "{{days}} days ago",
"continueWith": "Continue with {{plan}}", "continueWith": "Continue with {{plan}}",
"changeToPlan": "Change to {{plan}}", "changeToPlan": "Change to {{plan}}",
"creditPlan": "Credit Plan", "creditPlan": "Credit Plan",

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

@@ -2,4 +2,4 @@
"title": "Appearance", "title": "Appearance",
"darkMode": "Dark Mode", "darkMode": "Dark Mode",
"darkModeDescription": "Switch between light and dark mode to customize your viewing experience." "darkModeDescription": "Switch between light and dark mode to customize your viewing experience."
} }

View File

@@ -27,4 +27,4 @@
"add-sub-task": "+ Add Sub Task", "add-sub-task": "+ Add Sub Task",
"refresh-sub-tasks": "Refresh Sub Tasks" "refresh-sub-tasks": "Refresh Sub Tasks"
} }
} }

View File

@@ -31,4 +31,4 @@
"intervalWeeks": "Interval (weeks)", "intervalWeeks": "Interval (weeks)",
"intervalMonths": "Interval (months)", "intervalMonths": "Interval (months)",
"saveChanges": "Save Changes" "saveChanges": "Save Changes"
} }

View File

@@ -47,7 +47,7 @@
"searchInputPlaceholder": "Search or create", "searchInputPlaceholder": "Search or create",
"assigneeSelectorInviteButton": "Invite a new member by email", "assigneeSelectorInviteButton": "Invite a new member by email",
"labelInputPlaceholder": "Search or create", "labelInputPlaceholder": "Search or create",
"pendingInvitation": "Pending Invitation", "pendingInvitation": "Pending Invitation",
"contextMenu": { "contextMenu": {

View File

@@ -12,4 +12,4 @@
"enterSubtaskName": "Enter subtask name...", "enterSubtaskName": "Enter subtask name...",
"add": "Add", "add": "Add",
"cancel": "Cancel" "cancel": "Cancel"
} }

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

@@ -24,7 +24,7 @@
"paymentMethod": "Método de Pago", "paymentMethod": "Método de Pago",
"status": "Estado", "status": "Estado",
"ltdUsers": "Puedes agregar hasta {{ltd_users}} usuarios.", "ltdUsers": "Puedes agregar hasta {{ltd_users}} usuarios.",
"drawerTitle": "Canjear Código", "drawerTitle": "Canjear Código",
"label": "Canjear Código", "label": "Canjear Código",
"drawerPlaceholder": "Ingrese su código de canje", "drawerPlaceholder": "Ingrese su código de canje",
@@ -98,7 +98,7 @@
"perMonthPerUser": "por usuario / mes", "perMonthPerUser": "por usuario / mes",
"viewInvoice": "Ver Factura", "viewInvoice": "Ver Factura",
"switchToFreePlan": "Cambiar a Plan Gratuito", "switchToFreePlan": "Cambiar a Plan Gratuito",
"expirestoday": "hoy", "expirestoday": "hoy",
"expirestomorrow": "mañana", "expirestomorrow": "mañana",
"expiredDaysAgo": "hace {{days}} días", "expiredDaysAgo": "hace {{days}} días",

View File

@@ -20,4 +20,4 @@
"newTaskNamePlaceholder": "Escribe un nombre de tarea", "newTaskNamePlaceholder": "Escribe un nombre de tarea",
"newSubtaskNamePlaceholder": "Escribe un nombre de subtarea" "newSubtaskNamePlaceholder": "Escribe un nombre de subtarea"
} }

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

@@ -10,7 +10,7 @@
"taskIncludes": "¿Qué se debe incluir en la plantilla de las tareas?", "taskIncludes": "¿Qué se debe incluir en la plantilla de las tareas?",
"taskIncludesOptions": { "taskIncludesOptions": {
"statuses": "Estados", "statuses": "Estados",
"phases": "Fases", "phases": "Fases",
"labels": "Etiquetas", "labels": "Etiquetas",
"name": "Nombre", "name": "Nombre",
"priority": "Prioridad", "priority": "Prioridad",

View File

@@ -2,4 +2,4 @@
"title": "Apariencia", "title": "Apariencia",
"darkMode": "Modo Oscuro", "darkMode": "Modo Oscuro",
"darkModeDescription": "Cambia entre el modo claro y oscuro para personalizar tu experiencia visual." "darkModeDescription": "Cambia entre el modo claro y oscuro para personalizar tu experiencia visual."
} }

View File

@@ -27,4 +27,4 @@
"add-sub-task": "+ Añadir subtarea", "add-sub-task": "+ Añadir subtarea",
"refresh-sub-tasks": "Actualizar subtareas" "refresh-sub-tasks": "Actualizar subtareas"
} }
} }

View File

@@ -31,4 +31,4 @@
"intervalWeeks": "Intervalo (semanas)", "intervalWeeks": "Intervalo (semanas)",
"intervalMonths": "Intervalo (meses)", "intervalMonths": "Intervalo (meses)",
"saveChanges": "Guardar cambios" "saveChanges": "Guardar cambios"
} }

View File

@@ -90,4 +90,4 @@
"cancelMarkAsDone": "No, mantener estado actual", "cancelMarkAsDone": "No, mantener estado actual",
"markAsDoneDescription": "Has establecido el progreso al 100%. ¿Quieres actualizar el estado de la tarea a \"Completada\"?" "markAsDoneDescription": "Has establecido el progreso al 100%. ¿Quieres actualizar el estado de la tarea a \"Completada\"?"
} }
} }

View File

@@ -47,7 +47,7 @@
"searchInputPlaceholder": "Buscar o crear", "searchInputPlaceholder": "Buscar o crear",
"assigneeSelectorInviteButton": "Invitar a un nuevo miembro por correo", "assigneeSelectorInviteButton": "Invitar a un nuevo miembro por correo",
"labelInputPlaceholder": "Buscar o crear", "labelInputPlaceholder": "Buscar o crear",
"pendingInvitation": "Invitación pendiente", "pendingInvitation": "Invitación pendiente",
"contextMenu": { "contextMenu": {

View File

@@ -12,4 +12,4 @@
"enterSubtaskName": "Ingresa el nombre de la subtarea...", "enterSubtaskName": "Ingresa el nombre de la subtarea...",
"add": "Añadir", "add": "Añadir",
"cancel": "Cancelar" "cancel": "Cancelar"
} }

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

@@ -98,7 +98,7 @@
"perMonthPerUser": "por usuário / mês", "perMonthPerUser": "por usuário / mês",
"viewInvoice": "Ver Fatura", "viewInvoice": "Ver Fatura",
"switchToFreePlan": "Mudar para Plano Gratuito", "switchToFreePlan": "Mudar para Plano Gratuito",
"expirestoday": "hoje", "expirestoday": "hoje",
"expirestomorrow": "amanhã", "expirestomorrow": "amanhã",
"expiredDaysAgo": "há {{days}} dias", "expiredDaysAgo": "há {{days}} dias",

View File

@@ -20,4 +20,4 @@
"newTaskNamePlaceholder": "Escreva um nome de tarefa", "newTaskNamePlaceholder": "Escreva um nome de tarefa",
"newSubtaskNamePlaceholder": "Escreva um nome de subtarefa" "newSubtaskNamePlaceholder": "Escreva um nome de subtarefa"
} }

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

@@ -11,7 +11,7 @@
"taskIncludesOptions": { "taskIncludesOptions": {
"statuses": "Status", "statuses": "Status",
"phases": "Fases", "phases": "Fases",
"labels": "Etiquetas", "labels": "Etiquetas",
"name": "Nome", "name": "Nome",
"priority": "Prioridade", "priority": "Prioridade",
"status": "Status", "status": "Status",

View File

@@ -2,4 +2,4 @@
"title": "Aparência", "title": "Aparência",
"darkMode": "Modo Escuro", "darkMode": "Modo Escuro",
"darkModeDescription": "Alterne entre o modo claro e escuro para personalizar sua experiência de visualização." "darkModeDescription": "Alterne entre o modo claro e escuro para personalizar sua experiência de visualização."
} }

View File

@@ -7,4 +7,4 @@
"searchPlaceholder": "Pesquisar por nome", "searchPlaceholder": "Pesquisar por nome",
"emptyText": "As categorias podem ser criadas ao atualizar ou criar projetos.", "emptyText": "As categorias podem ser criadas ao atualizar ou criar projetos.",
"colorChangeTooltip": "Clique para mudar a cor" "colorChangeTooltip": "Clique para mudar a cor"
} }

View File

@@ -19,4 +19,4 @@
"createClientErrorMessage": "Criar cliente falhou!", "createClientErrorMessage": "Criar cliente falhou!",
"updateClientSuccessMessage": "Atualizar cliente sucesso!", "updateClientSuccessMessage": "Atualizar cliente sucesso!",
"updateClientErrorMessage": "Atualizar cliente falhou!" "updateClientErrorMessage": "Atualizar cliente falhou!"
} }

View File

@@ -17,4 +17,4 @@
"createJobTitleErrorMessage": "Falha ao criar título de emprego!", "createJobTitleErrorMessage": "Falha ao criar título de emprego!",
"updateJobTitleSuccessMessage": "Atualizar título de emprego com sucesso!", "updateJobTitleSuccessMessage": "Atualizar título de emprego com sucesso!",
"updateJobTitleErrorMessage": "Falha ao atualizar título de emprego!" "updateJobTitleErrorMessage": "Falha ao atualizar título de emprego!"
} }

View File

@@ -8,4 +8,4 @@
"emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.", "emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.",
"pinTooltip": "Clique para fixar isso no menu principal", "pinTooltip": "Clique para fixar isso no menu principal",
"colorChangeTooltip": "Clique para mudar a cor" "colorChangeTooltip": "Clique para mudar a cor"
} }

View File

@@ -4,4 +4,4 @@
"time_zone": "Fuso horário", "time_zone": "Fuso horário",
"time_zone_required": "O fuso horário é obrigatório", "time_zone_required": "O fuso horário é obrigatório",
"save_changes": "Salvar alterações" "save_changes": "Salvar alterações"
} }

View File

@@ -7,4 +7,4 @@
"popupDescription": "As notificações pop-up podem ser desativadas pelo seu navegador. Altere as configurações do seu navegador para permiti-las.", "popupDescription": "As notificações pop-up podem ser desativadas pelo seu navegador. Altere as configurações do seu navegador para permiti-las.",
"unreadItemsTitle": "Mostrar o número de itens não lidos", "unreadItemsTitle": "Mostrar o número de itens não lidos",
"unreadItemsDescription": "Você verá contagens para cada notificação." "unreadItemsDescription": "Você verá contagens para cada notificação."
} }

View File

@@ -10,4 +10,4 @@
"profileJoinedText": "Entrou há um mês", "profileJoinedText": "Entrou há um mês",
"profileLastUpdatedText": "Última atualização há um mês", "profileLastUpdatedText": "Última atualização há um mês",
"avatarTooltip": "Clique para carregar um avatar" "avatarTooltip": "Clique para carregar um avatar"
} }

View File

@@ -5,4 +5,4 @@
"confirmText": "Tem a certeza?", "confirmText": "Tem a certeza?",
"okText": "Sim", "okText": "Sim",
"cancelText": "Cancelar" "cancelText": "Cancelar"
} }

View File

@@ -12,4 +12,4 @@
"change-password": "Alterar Senha", "change-password": "Alterar Senha",
"language-and-region": "Idioma e Região", "language-and-region": "Idioma e Região",
"appearance": "Aparência" "appearance": "Aparência"
} }

View File

@@ -6,4 +6,4 @@
"confirmText": "Tem a certeza?", "confirmText": "Tem a certeza?",
"okText": "Sim", "okText": "Sim",
"cancelText": "Cancelar" "cancelText": "Cancelar"
} }

View File

@@ -41,4 +41,4 @@
"addedText": "Adicionado", "addedText": "Adicionado",
"updatedText": "Atualizado", "updatedText": "Atualizado",
"noResultFound": "Digite um endereço de email e pressione enter..." "noResultFound": "Digite um endereço de email e pressione enter..."
} }

View File

@@ -27,4 +27,4 @@
"add-sub-task": "+ Adicionar subtarefa", "add-sub-task": "+ Adicionar subtarefa",
"refresh-sub-tasks": "Atualizar subtarefas" "refresh-sub-tasks": "Atualizar subtarefas"
} }
} }

View File

@@ -31,4 +31,4 @@
"intervalWeeks": "Intervalo (semanas)", "intervalWeeks": "Intervalo (semanas)",
"intervalMonths": "Intervalo (meses)", "intervalMonths": "Intervalo (meses)",
"saveChanges": "Salvar alterações" "saveChanges": "Salvar alterações"
} }

View File

@@ -90,4 +90,4 @@
"cancelMarkAsDone": "Não, manter status atual", "cancelMarkAsDone": "Não, manter status atual",
"markAsDoneDescription": "Você definiu o progresso como 100%. Deseja atualizar o status da tarefa para \"Concluída\"?" "markAsDoneDescription": "Você definiu o progresso como 100%. Deseja atualizar o status da tarefa para \"Concluída\"?"
} }
} }

View File

@@ -34,7 +34,7 @@
"completeddateText": "Data de Conclusão", "completeddateText": "Data de Conclusão",
"createddateText": "Data de Criação", "createddateText": "Data de Criação",
"lastupdatedText": "Última Atualização", "lastupdatedText": "Última Atualização",
"lowText": "Baixa", "lowText": "Baixa",
"mediumText": "Média", "mediumText": "Média",
"highText": "Alta", "highText": "Alta",

View File

@@ -47,7 +47,7 @@
"searchInputPlaceholder": "Buscar ou criar", "searchInputPlaceholder": "Buscar ou criar",
"assigneeSelectorInviteButton": "Convide um novo membro por e-mail", "assigneeSelectorInviteButton": "Convide um novo membro por e-mail",
"labelInputPlaceholder": "Buscar ou criar", "labelInputPlaceholder": "Buscar ou criar",
"pendingInvitation": "Convite Pendente", "pendingInvitation": "Convite Pendente",
"contextMenu": { "contextMenu": {

View File

@@ -12,4 +12,4 @@
"enterSubtaskName": "Digite o nome da subtarefa...", "enterSubtaskName": "Digite o nome da subtarefa...",
"add": "Adicionar", "add": "Adicionar",
"cancel": "Cancelar" "cancel": "Cancelar"
} }

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,9 +1,9 @@
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) {
// Mark that we've attempted to unregister // Mark that we've attempted to unregister
sessionStorage.setItem('swUnregisterAttempted', 'true'); sessionStorage.setItem('swUnregisterAttempted', 'true');
@@ -14,10 +14,10 @@ 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

@@ -16,7 +16,7 @@ copyFolderRecursiveSync(sourceDir, path.join(__dirname, '..', 'public'));
function copyFolderRecursiveSync(source, target) { function copyFolderRecursiveSync(source, target) {
const targetFolder = path.join(target, path.basename(source)); const targetFolder = path.join(target, path.basename(source));
// Create target folder if it doesn't exist // Create target folder if it doesn't exist
if (!fs.existsSync(targetFolder)) { if (!fs.existsSync(targetFolder)) {
fs.mkdirSync(targetFolder); fs.mkdirSync(targetFolder);
@@ -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);
@@ -36,4 +36,4 @@ function copyFolderRecursiveSync(source, target) {
} }
} }
console.log('TinyMCE files copied successfully!'); console.log('TinyMCE files copied successfully!');

View File

@@ -22,7 +22,7 @@ import { SuspenseFallback } from './components/suspense-fallback/suspense-fallba
/** /**
* Main App Component - Performance Optimized * Main App Component - Performance Optimized
* *
* Performance optimizations applied: * Performance optimizations applied:
* 1. React.memo() - Prevents unnecessary re-renders * 1. React.memo() - Prevents unnecessary re-renders
* 2. useMemo() - Memoizes expensive computations * 2. useMemo() - Memoizes expensive computations
@@ -37,7 +37,7 @@ const App: React.FC = memo(() => {
// Memoize mixpanel initialization to prevent re-initialization // Memoize mixpanel initialization to prevent re-initialization
const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []); const mixpanelToken = useMemo(() => import.meta.env.VITE_MIXPANEL_TOKEN as string, []);
useEffect(() => { useEffect(() => {
initMixpanel(mixpanelToken); initMixpanel(mixpanelToken);
}, [mixpanelToken]); }, [mixpanelToken]);
@@ -60,12 +60,12 @@ const App: React.FC = memo(() => {
// Initialize CSRF token and translations on app startup // Initialize CSRF token and translations on app startup
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const initializeApp = async () => { const initializeApp = async () => {
try { try {
// Initialize CSRF token // Initialize CSRF token
await initializeCsrfToken(); await initializeCsrfToken();
// Preload essential translations // Preload essential translations
await ensureTranslationsLoaded(); await ensureTranslationsLoaded();
} catch (error) { } catch (error) {
@@ -85,11 +85,11 @@ const App: React.FC = memo(() => {
return ( return (
<Suspense fallback={<SuspenseFallback />}> <Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper> <ThemeWrapper>
<RouterProvider <RouterProvider
router={router} router={router}
future={{ future={{
v7_startTransition: true v7_startTransition: true,
}} }}
/> />
</ThemeWrapper> </ThemeWrapper>
</Suspense> </Suspense>

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;
}, },
@@ -225,7 +242,7 @@ export const adminCenterApiService = {
async resumeSubscription(): Promise<IServerResponse<any>> { async resumeSubscription(): Promise<IServerResponse<any>> {
const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/resume-plan`); const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/resume-plan`);
return response.data; return response.data;
}, },
async cancelSubscription(): Promise<IServerResponse<any>> { async cancelSubscription(): Promise<IServerResponse<any>> {
const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/cancel-plan`); const response = await apiClient.get<IServerResponse<any>>(`${rootUrl}/billing/cancel-plan`);
@@ -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

@@ -16,16 +16,16 @@ export const refreshCsrfToken = async (): Promise<string | null> => {
try { try {
const tokenStart = performance.now(); const tokenStart = performance.now();
console.log('[CSRF] Starting CSRF token refresh...'); console.log('[CSRF] Starting CSRF token refresh...');
// 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();
console.log(`[CSRF] CSRF token refresh completed in ${(tokenEnd - tokenStart).toFixed(2)}ms`); console.log(`[CSRF] CSRF token refresh completed in ${(tokenEnd - tokenStart).toFixed(2)}ms`);
if (response.data && response.data.token) { if (response.data && response.data.token) {
csrfToken = response.data.token; csrfToken = response.data.token;
console.log('[CSRF] CSRF token successfully refreshed'); console.log('[CSRF] CSRF token successfully refreshed');
@@ -61,22 +61,22 @@ const apiClient = axios.create({
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
async config => { async config => {
const requestStart = performance.now(); const requestStart = performance.now();
// Ensure we have a CSRF token before making requests // Ensure we have a CSRF token before making requests
if (!csrfToken) { if (!csrfToken) {
const tokenStart = performance.now(); const tokenStart = performance.now();
await refreshCsrfToken(); await refreshCsrfToken();
const tokenEnd = performance.now(); const tokenEnd = performance.now();
} }
if (csrfToken) { if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken; config.headers['X-CSRF-Token'] = csrfToken;
} else { } else {
console.warn('No CSRF token available after refresh attempt'); console.warn('No CSRF token available after refresh attempt');
} }
const requestEnd = performance.now(); const requestEnd = performance.now();
return config; return config;
}, },
error => Promise.reject(error) error => Promise.reject(error)
@@ -114,14 +114,17 @@ 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
const newToken = await refreshCsrfToken(); const newToken = await refreshCsrfToken();
if (newToken && error.config) { if (newToken && error.config) {

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

@@ -20,7 +20,7 @@ const api = createApi({
if (!token) { if (!token) {
token = await refreshCsrfToken(); token = await refreshCsrfToken();
} }
if (token) { if (token) {
headers.set('X-CSRF-Token', token); headers.set('X-CSRF-Token', token);
} }

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>> => {
const response = await apiClient.post(`${rootUrl}/import-custom-template`, body);
return response.data;
}, },
createFromCustomTemplate: async (body: {
template_id: string;
}): Promise<IServerResponse<IProjectTemplate>> => {
const response = await apiClient.post(`${rootUrl}/import-custom-template`, body);
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

@@ -20,7 +20,7 @@ export const projectsApi = createApi({
if (!token) { if (!token) {
token = await refreshCsrfToken(); token = await refreshCsrfToken();
} }
if (token) { if (token) {
headers.set('X-CSRF-Token', token); headers.set('X-CSRF-Token', token);
} }

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

@@ -52,8 +52,11 @@ export const profileSettingsApiService = {
return response.data; return response.data;
}, },
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,18 +19,26 @@ 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 (
const q = toQueryString({ index, size }); projectId: string,
index: number,
size: number
): Promise<IServerResponse<IProjectAttachmentsViewModel>> => {
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;
}, },
@@ -18,4 +20,4 @@ export const taskDependenciesApiService = {
const response = await apiClient.delete(`${rootUrl}/${dependencyId}`); const response = await apiClient.delete(`${rootUrl}/${dependencyId}`);
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,23 +1,23 @@
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[]>> => {
const response = await apiClient.get(`/api/v1/custom-columns/project/${projectId}/columns`); const response = await apiClient.get(`/api/v1/custom-columns/project/${projectId}/columns`);
return response.data; return response.data;
}, },
updateTaskCustomColumnValue: async ( updateTaskCustomColumnValue: async (
taskId: string, taskId: string,
columnKey: string, columnKey: string,
value: string | number | boolean, value: string | number | boolean,
projectId: string projectId: string
): Promise<IServerResponse<any>> => { ): Promise<IServerResponse<any>> => {
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

@@ -15,7 +15,7 @@ class ReduxPerformanceMonitor {
logMetric(metric: PerformanceMetrics) { logMetric(metric: PerformanceMetrics) {
this.metrics.push(metric); this.metrics.push(metric);
// Keep only recent metrics // Keep only recent metrics
if (this.metrics.length > this.maxMetrics) { if (this.metrics.length > this.maxMetrics) {
this.metrics = this.metrics.slice(-this.maxMetrics); this.metrics = this.metrics.slice(-this.maxMetrics);
@@ -49,14 +49,14 @@ 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);
const end = performance.now(); const end = performance.now();
const duration = end - start; const duration = end - start;
// Calculate approximate state size (in development only) // Calculate approximate state size (in development only)
let stateSize = 0; let stateSize = 0;
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
@@ -101,7 +101,7 @@ export function analyzeReduxPerformance() {
// Count action frequencies // Count action frequencies
metrics.forEach(m => { metrics.forEach(m => {
analysis.mostFrequentActions[m.actionType] = analysis.mostFrequentActions[m.actionType] =
(analysis.mostFrequentActions[m.actionType] || 0) + 1; (analysis.mostFrequentActions[m.actionType] || 0) + 1;
}); });
@@ -109,14 +109,15 @@ export function analyzeReduxPerformance() {
if (analysis.slowActions > analysis.totalActions * 0.1) { if (analysis.slowActions > analysis.totalActions * 0.1) {
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');
} }
if (analysis.averageActionTime > 20) { if (analysis.averageActionTime > 20) {
analysis.recommendations.push('Average action time is high - check for expensive reducers'); analysis.recommendations.push('Average action time is high - check for expensive reducers');
} }
return analysis; return analysis;
} }

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
} }
@@ -75,7 +77,7 @@ export const AdminGuard = memo(({ children }: GuardProps) => {
const currentSession = authService.getCurrentSession(); const currentSession = authService.getCurrentSession();
const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE; const isFreePlan = currentSession?.subscription_type === ISUBSCRIPTION_TYPE.FREE;
if (!authService.isOwnerOrAdmin() || isFreePlan) { if (!authService.isOwnerOrAdmin() || isFreePlan) {
return { redirect: '/worklenz/unauthorized' }; return { redirect: '/worklenz/unauthorized' };
} }
@@ -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
} }
@@ -120,37 +124,40 @@ export const LicenseExpiryGuard = memo(({ children }: GuardProps) => {
const currentSession = authService.getCurrentSession(); const currentSession = authService.getCurrentSession();
// Check if trial is expired more than 7 days or if is_expired flag is set // Check if trial is expired more than 7 days or if is_expired flag is set
const isLicenseExpiredMoreThan7Days = () => { const isLicenseExpiredMoreThan7Days = () => {
// Quick bail if no session data is available // Quick bail if no session data is available
if (!currentSession) return false; if (!currentSession) return false;
// Check is_expired flag first // Check is_expired flag first
if (currentSession.is_expired) { if (currentSession.is_expired) {
// If no trial_expire_date exists but is_expired is true, defer to backend check // If no trial_expire_date exists but is_expired is true, defer to backend check
if (!currentSession.trial_expire_date) return true; if (!currentSession.trial_expire_date) return true;
// If there is a trial_expire_date, check if it's more than 7 days past // If there is a trial_expire_date, check if it's more than 7 days past
const today = new Date(); const today = new Date();
const expiryDate = new Date(currentSession.trial_expire_date); const expiryDate = new Date(currentSession.trial_expire_date);
const diffTime = today.getTime() - expiryDate.getTime(); const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Redirect if more than 7 days past expiration // Redirect if more than 7 days past expiration
return diffDays > 7; return diffDays > 7;
} }
// 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);
const diffTime = today.getTime() - expiryDate.getTime(); const diffTime = today.getTime() - expiryDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// If expired more than 7 days, redirect // If expired more than 7 days, redirect
return diffDays > 7; return diffDays > 7;
} }
// No expiration data found // No expiration data found
return false; return false;
}; };
@@ -227,30 +234,34 @@ 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>
<p style={{ fontSize: '16px', color: '#555', marginBottom: '24px' }}> <p style={{ fontSize: '16px', color: '#555', marginBottom: '24px' }}>
Please upgrade now to continue using Worklenz. Please upgrade now to continue using Worklenz.
</p> </p>
<button <button
style={{ style={{
backgroundColor: '#1890ff', backgroundColor: '#1890ff',
color: 'white', color: 'white',
@@ -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}</>;
} }
@@ -52,21 +56,21 @@ const mainRoutes: RouteObject[] = [
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{ index: true, element: <Navigate to="home" replace /> }, { index: true, element: <Navigate to="home" replace /> },
{ {
path: 'home', path: 'home',
element: ( element: (
<Suspense fallback={<SuspenseFallback />}> <Suspense fallback={<SuspenseFallback />}>
<HomePage /> <HomePage />
</Suspense> </Suspense>
) ),
}, },
{ {
path: 'projects', path: 'projects',
element: ( element: (
<Suspense fallback={<SuspenseFallback />}> <Suspense fallback={<SuspenseFallback />}>
<ProjectList /> <ProjectList />
</Suspense> </Suspense>
) ),
}, },
{ {
path: 'schedule', path: 'schedule',
@@ -76,15 +80,15 @@ const mainRoutes: RouteObject[] = [
<Schedule /> <Schedule />
</AdminGuard> </AdminGuard>
</Suspense> </Suspense>
) ),
}, },
{ {
path: `projects/:projectId`, path: `projects/:projectId`,
element: ( element: (
<Suspense fallback={<SuspenseFallback />}> <Suspense fallback={<SuspenseFallback />}>
<ProjectView /> <ProjectView />
</Suspense> </Suspense>
) ),
}, },
{ {
path: `settings/project-templates/edit/:templateId/:templateName`, path: `settings/project-templates/edit/:templateId/:templateName`,
@@ -94,13 +98,13 @@ const mainRoutes: RouteObject[] = [
</Suspense> </Suspense>
), ),
}, },
{ {
path: 'unauthorized', path: 'unauthorized',
element: ( element: (
<Suspense fallback={<SuspenseFallback />}> <Suspense fallback={<SuspenseFallback />}>
<Unauthorized /> <Unauthorized />
</Suspense> </Suspense>
) ),
}, },
...settingsRoutes, ...settingsRoutes,
...adminCenterRoutes, ...adminCenterRoutes,
@@ -113,15 +117,15 @@ export const licenseExpiredRoute: RouteObject = {
path: '/worklenz', path: '/worklenz',
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{ {
path: 'license-expired', path: 'license-expired',
element: ( element: (
<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

@@ -122,7 +122,7 @@ export const store = configureStore({
taskListCustomColumnsReducer: taskListCustomColumnsReducer, taskListCustomColumnsReducer: taskListCustomColumnsReducer,
boardReducer: boardReducer, boardReducer: boardReducer,
projectDrawerReducer: projectDrawerReducer, projectDrawerReducer: projectDrawerReducer,
projectViewReducer: projectViewReducer, projectViewReducer: projectViewReducer,
// Project Lookups // Project Lookups

View File

@@ -22,10 +22,10 @@ interface AssigneeSelectorProps {
isDarkMode?: boolean; isDarkMode?: boolean;
} }
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,10 +78,12 @@ 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();
} else { } else {
@@ -98,7 +104,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true); window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('scroll', handleScroll, true);
@@ -113,10 +119,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const handleDropdownToggle = (e: React.MouseEvent) => { const handleDropdownToggle = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!isOpen) { if (!isOpen) {
updateDropdownPosition(); updateDropdownPosition();
// Prepare team members data when opening // Prepare team members data when opening
const assignees = task?.assignees?.map(assignee => assignee.team_member_id); const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
const membersData = (members?.data || []).map(member => ({ const membersData = (members?.data || []).map(member => ({
@@ -125,7 +131,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
})); }));
const sortedMembers = sortTeamMembers(membersData); const sortedMembers = sortTeamMembers(membersData);
setTeamMembers({ data: sortedMembers }); setTeamMembers({ data: sortedMembers });
setIsOpen(true); setIsOpen(true);
// Focus search input after opening // Focus search input after opening
setTimeout(() => { setTimeout(() => {
@@ -160,11 +166,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Update local team members state for dropdown UI // Update local team members state for dropdown UI
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,149 +219,159 @@ 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
)} )}
</> </>
); );
}; };
export default AssigneeSelector; export default AssigneeSelector;

View File

@@ -11,47 +11,63 @@ interface AvatarProps {
style?: React.CSSProperties; style?: React.CSSProperties;
} }
const Avatar: React.FC<AvatarProps> = ({ const Avatar: React.FC<AvatarProps> = ({
name = '', name = '',
size = 'default', size = 'default',
isDarkMode = false, isDarkMode = false,
className = '', className = '',
src, src,
backgroundColor, backgroundColor,
onClick, onClick,
style = {} style = {},
}) => { }) => {
// Handle both numeric and string sizes // Handle both numeric and string sizes
const getSize = () => { const getSize = () => {
if (typeof size === 'number') { if (typeof size === 'number') {
return { width: size, height: size, fontSize: `${size * 0.4}px` }; return { width: size, height: size, fontSize: `${size * 0.4}px` };
} }
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];
}; };
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;
const colorIndex = name.charCodeAt(0) % colors.length; const colorIndex = name.charCodeAt(0) % colors.length;
const defaultBgColor = backgroundColor || colors[colorIndex]; const defaultBgColor = backgroundColor || colors[colorIndex];
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onClick?.(e); onClick?.(e);
@@ -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) {
@@ -74,9 +90,9 @@ const Avatar: React.FC<AvatarProps> = ({
/> />
); );
} }
return ( return (
<div <div
onClick={handleClick} onClick={handleClick}
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm cursor-pointer ${className}`} className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm cursor-pointer ${className}`}
style={avatarStyle} style={avatarStyle}
@@ -86,4 +102,4 @@ const Avatar: React.FC<AvatarProps> = ({
); );
}; };
export default Avatar; export default Avatar;

View File

@@ -20,42 +20,49 @@ interface AvatarGroupProps {
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
} }
const AvatarGroup: React.FC<AvatarGroupProps> = ({ const AvatarGroup: React.FC<AvatarGroupProps> = ({
members, members,
maxCount, maxCount,
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 =
return ( member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase();
<Tooltip
key={member.team_member_id || member.id || index} return (
title={memberName} <Tooltip
isDarkMode={isDarkMode} key={member.team_member_id || member.id || index}
> title={memberName}
<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;
@@ -73,13 +80,13 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
if (typeof size === 'number') { if (typeof size === 'number') {
return { width: size, height: size, fontSize: `${size * 0.4}px` }; return { width: size, height: size, fontSize: `${size * 0.4}px` };
} }
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`} <div
isDarkMode={isDarkMode}
>
<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}
@@ -108,4 +110,4 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
); );
}; };
export default AvatarGroup; export default AvatarGroup;

View File

@@ -12,25 +12,25 @@ interface ButtonProps {
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
} }
const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({ const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
children, children,
onClick, onClick,
variant = 'default', variant = 'default',
size = 'default', size = 'default',
className = '', className = '',
icon, icon,
isDarkMode = false, isDarkMode = false,
disabled = false, disabled = false,
type = 'button', type = 'button',
...props ...props
}) => { }) => {
const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-2 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`; const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-2 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`;
const variantClasses = { const variantClasses = {
text: isDarkMode text: isDarkMode
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50' ? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50'
: 'text-gray-600 hover:text-gray-800 hover:bg-gray-100', : 'text-gray-600 hover:text-gray-800 hover:bg-gray-100',
default: isDarkMode default: isDarkMode
? 'bg-gray-800 border border-gray-600 text-gray-200 hover:bg-gray-700' ? 'bg-gray-800 border border-gray-600 text-gray-200 hover:bg-gray-700'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50', : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50',
primary: isDarkMode primary: isDarkMode
@@ -38,15 +38,15 @@ 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 (
<button <button
type={type} type={type}
@@ -55,10 +55,10 @@ 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>
); );
}; };
export default Button; export default Button;

Some files were not shown because too many files have changed in this diff Show More