diff --git a/worklenz-backend/src/controllers/project-finance-controller.ts b/worklenz-backend/src/controllers/project-finance-controller.ts index b76aa0b6..bd78153e 100644 --- a/worklenz-backend/src/controllers/project-finance-controller.ts +++ b/worklenz-backend/src/controllers/project-finance-controller.ts @@ -50,6 +50,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ): Promise { const projectId = req.params.project_id; const groupBy = req.query.group_by || "status"; + const billableFilter = req.query.billable_filter || "billable"; // Get project information including currency const projectQuery = ` @@ -82,6 +83,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const rateCardResult = await db.query(rateCardQuery, [projectId]); const projectRateCards = rateCardResult.rows; + // Build billable filter condition + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + // Get tasks with their financial data - support hierarchical loading const q = ` WITH RECURSIVE task_tree AS ( @@ -106,6 +115,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE t.project_id = $1 AND t.archived = false AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} UNION ALL @@ -579,6 +589,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ): Promise { const projectId = req.params.project_id; const parentTaskId = req.params.parent_task_id; + const billableFilter = req.query.billable_filter || "billable"; if (!parentTaskId) { return res @@ -586,6 +597,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase { .send(new ServerResponse(false, null, "Parent task ID is required")); } + // Build billable filter condition for subtasks + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + // Get subtasks with their financial data const q = ` WITH task_costs AS ( @@ -607,6 +626,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE t.project_id = $1 AND t.archived = false AND t.parent_task_id = $2 + ${billableCondition} ), task_estimated_costs AS ( SELECT @@ -721,6 +741,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { ): Promise { const projectId = req.params.project_id; const groupBy = (req.query.groupBy as string) || "status"; + const billableFilter = req.query.billable_filter || "billable"; // Get project name and currency for filename and export const projectQuery = `SELECT name, currency FROM projects WHERE id = $1`; @@ -746,6 +767,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase { const rateCardResult = await db.query(rateCardQuery, [projectId]); const projectRateCards = rateCardResult.rows; + // Build billable filter condition for export + let billableCondition = ""; + if (billableFilter === "billable") { + billableCondition = "AND t.billable = true"; + } else if (billableFilter === "non-billable") { + billableCondition = "AND t.billable = false"; + } + // Get tasks with their financial data - support hierarchical loading const q = ` WITH RECURSIVE task_tree AS ( @@ -770,6 +799,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase { WHERE t.project_id = $1 AND t.archived = false AND t.parent_task_id IS NULL -- Only load parent tasks initially + ${billableCondition} UNION ALL diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json index 642422b2..de496e36 100644 --- a/worklenz-frontend/public/locales/en/project-view-finance.json +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -8,12 +8,16 @@ "exportButton": "Export", "currencyText": "Currency", "importButton": "Import", + "filterText": "Filter", + "billableOnlyText": "Billable Only", + "nonBillableOnlyText": "Non-Billable Only", + "allTasksText": "All Tasks", "taskColumn": "Task", "membersColumn": "Members", "hoursColumn": "Estimated Hours", "totalTimeLoggedColumn": "Total Time Logged", - "costColumn": "Cost", + "costColumn": "Actual Cost", "estimatedCostColumn": "Estimated Cost", "fixedCostColumn": "Fixed Cost", "totalBudgetedCostColumn": "Total Budgeted Cost", diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json index 5e2c984b..3607b7ee 100644 --- a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -2,27 +2,49 @@ "nameColumn": "Name", "createdColumn": "Created", "noProjectsAvailable": "No projects available", - "deleteConfirmationTitle": "Are you sure?", - "deleteConfirmationOk": "Yes", + "deleteConfirmationTitle": "Are you sure you want to delete this rate card?", + "deleteConfirmationOk": "Yes, delete", "deleteConfirmationCancel": "Cancel", - "searchPlaceholder": "Search by name", + "searchPlaceholder": "Search rate cards by name", "createRatecard": "Create Rate Card", + "editTooltip": "Edit rate card", + "deleteTooltip": "Delete rate card", + "fetchError": "Failed to fetch rate cards", + "createError": "Failed to create rate card", + "deleteSuccess": "Rate card deleted successfully", + "deleteError": "Failed to delete rate card", "jobTitleColumn": "Job title", "ratePerHourColumn": "Rate per hour", "saveButton": "Save", - "addRoleButton": "+ Add Role", - "createRatecardSuccessMessage": "Create Rate Card success!", - "createRatecardErrorMessage": "Create Rate Card failed!", - "updateRatecardSuccessMessage": "Update Rate Card success!", - "updateRatecardErrorMessage": "Update Rate Card failed!", + "addRoleButton": "Add Role", + "createRatecardSuccessMessage": "Rate card created successfully", + "createRatecardErrorMessage": "Failed to create rate card", + "updateRatecardSuccessMessage": "Rate card updated successfully", + "updateRatecardErrorMessage": "Failed to update rate card", "currency": "Currency", "actionsColumn": "Actions", "addAllButton": "Add All", "removeAllButton": "Remove All", "selectJobTitle": "Select job title", - "unsavedChangesTitle": "Unsaved changes", - "ratecardNameRequired": "Rate card name is required" - - + "unsavedChangesTitle": "You have unsaved changes", + "unsavedChangesMessage": "Do you want to save your changes before leaving?", + "unsavedChangesSave": "Save", + "unsavedChangesDiscard": "Discard", + "ratecardNameRequired": "Rate card name is required", + "ratecardNamePlaceholder": "Enter rate card name", + "noRatecardsFound": "No rate cards found", + "loadingRateCards": "Loading rate cards...", + "noJobTitlesAvailable": "No job titles available", + "noRolesAdded": "No roles added yet", + "createFirstJobTitle": "Create First Job Title", + "jobRolesTitle": "Job Roles", + "noJobTitlesMessage": "Please create job titles first in the Job Titles settings before adding roles to rate cards.", + "createNewJobTitle": "Create New Job Title", + "jobTitleNamePlaceholder": "Enter job title name", + "jobTitleNameRequired": "Job title name is required", + "jobTitleCreatedSuccess": "Job title created successfully", + "jobTitleCreateError": "Failed to create job title", + "createButton": "Create", + "cancelButton": "Cancel" } diff --git a/worklenz-frontend/public/locales/es/project-view-finance.json b/worklenz-frontend/public/locales/es/project-view-finance.json index bd2fa024..ad3ed662 100644 --- a/worklenz-frontend/public/locales/es/project-view-finance.json +++ b/worklenz-frontend/public/locales/es/project-view-finance.json @@ -8,27 +8,38 @@ "exportButton": "Exportar", "currencyText": "Moneda", "importButton": "Importar", + "filterText": "Filtro", + "billableOnlyText": "Solo Facturable", + "nonBillableOnlyText": "Solo No Facturable", + "allTasksText": "Todas las Tareas", "taskColumn": "Tarea", "membersColumn": "Miembros", "hoursColumn": "Horas Estimadas", "totalTimeLoggedColumn": "Tiempo Total Registrado", - "costColumn": "Costo", + "costColumn": "Costo Real", "estimatedCostColumn": "Costo Estimado", "fixedCostColumn": "Costo Fijo", "totalBudgetedCostColumn": "Costo Total Presupuestado", - "totalActualCostColumn": "Costo Total Real", - "varianceColumn": "Diferencia", + "totalActualCostColumn": "Costo Real Total", + "varianceColumn": "Varianza", "totalText": "Total", "noTasksFound": "No se encontraron tareas", "addRoleButton": "+ Agregar Rol", - "ratecardImportantNotice": "* Esta tarifa se genera en función de los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", + "ratecardImportantNotice": "* Esta tarifa se genera en base a los títulos de trabajo y tarifas estándar de la empresa. Sin embargo, tienes la flexibilidad de modificarla según el proyecto. Estos cambios no afectarán los títulos de trabajo y tarifas estándar de la organización.", "saveButton": "Guardar", "jobTitleColumn": "Título del Trabajo", "ratePerHourColumn": "Tarifa por hora", "ratecardPluralText": "Tarifas", - "labourHoursColumn": "Horas de Trabajo" + "labourHoursColumn": "Horas de Trabajo", + "actions": "Acciones", + "selectJobTitle": "Seleccionar Título del Trabajo", + "ratecardsPluralText": "Plantillas de Tarifas", + "deleteConfirm": "¿Estás seguro?", + "yes": "Sí", + "no": "No", + "alreadyImportedRateCardMessage": "Ya se ha importado una tarifa. Borra todas las tarifas importadas para agregar una nueva." } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/settings/ratecard-settings.json b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json index 825eabd5..2008d1f6 100644 --- a/worklenz-frontend/public/locales/es/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/es/settings/ratecard-settings.json @@ -2,19 +2,49 @@ "nameColumn": "Nombre", "createdColumn": "Creado", "noProjectsAvailable": "No hay proyectos disponibles", - "deleteConfirmationTitle": "¿Estás seguro?", - "deleteConfirmationOk": "Sí", + "deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta tarjeta de tarifas?", + "deleteConfirmationOk": "Sí, eliminar", "deleteConfirmationCancel": "Cancelar", - "searchPlaceholder": "Buscar por nombre", - "createRatecard": "Crear Tarifa", + "searchPlaceholder": "Buscar tarjetas de tarifas por nombre", + "createRatecard": "Crear Tarjeta de Tarifas", + "editTooltip": "Editar tarjeta de tarifas", + "deleteTooltip": "Eliminar tarjeta de tarifas", + "fetchError": "Error al cargar las tarjetas de tarifas", + "createError": "Error al crear la tarjeta de tarifas", + "deleteSuccess": "Tarjeta de tarifas eliminada con éxito", + "deleteError": "Error al eliminar la tarjeta de tarifas", - "jobTitleColumn": "Puesto de trabajo", + "jobTitleColumn": "Título del trabajo", "ratePerHourColumn": "Tarifa por hora", "saveButton": "Guardar", - "addRoleButton": "+ Agregar Rol", - "createRatecardSuccessMessage": "¡Tarifa creada con éxito!", - "createRatecardErrorMessage": "¡Error al crear la tarifa!", - "updateRatecardSuccessMessage": "¡Tarifa actualizada con éxito!", - "updateRatecardErrorMessage": "¡Error al actualizar la tarifa!", - "currency": "Moneda" + "addRoleButton": "Añadir Rol", + "createRatecardSuccessMessage": "Tarjeta de tarifas creada con éxito", + "createRatecardErrorMessage": "Error al crear la tarjeta de tarifas", + "updateRatecardSuccessMessage": "Tarjeta de tarifas actualizada con éxito", + "updateRatecardErrorMessage": "Error al actualizar la tarjeta de tarifas", + "currency": "Moneda", + "actionsColumn": "Acciones", + "addAllButton": "Añadir Todo", + "removeAllButton": "Eliminar Todo", + "selectJobTitle": "Seleccionar título del trabajo", + "unsavedChangesTitle": "Tiene cambios sin guardar", + "unsavedChangesMessage": "¿Desea guardar los cambios antes de salir?", + "unsavedChangesSave": "Guardar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "El nombre de la tarjeta de tarifas es obligatorio", + "ratecardNamePlaceholder": "Ingrese el nombre de la tarjeta de tarifas", + "noRatecardsFound": "No se encontraron tarjetas de tarifas", + "loadingRateCards": "Cargando tarjetas de tarifas...", + "noJobTitlesAvailable": "No hay títulos de trabajo disponibles", + "noRolesAdded": "Aún no se han añadido roles", + "createFirstJobTitle": "Crear Primer Título de Trabajo", + "jobRolesTitle": "Roles de Trabajo", + "noJobTitlesMessage": "Por favor, cree títulos de trabajo primero en la configuración de Títulos de Trabajo antes de añadir roles a las tarjetas de tarifas.", + "createNewJobTitle": "Crear Nuevo Título de Trabajo", + "jobTitleNamePlaceholder": "Ingrese el nombre del título de trabajo", + "jobTitleNameRequired": "El nombre del título de trabajo es obligatorio", + "jobTitleCreatedSuccess": "Título de trabajo creado con éxito", + "jobTitleCreateError": "Error al crear el título de trabajo", + "createButton": "Crear", + "cancelButton": "Cancelar" } diff --git a/worklenz-frontend/public/locales/pt/project-view-finance.json b/worklenz-frontend/public/locales/pt/project-view-finance.json index be3d31f2..7634b666 100644 --- a/worklenz-frontend/public/locales/pt/project-view-finance.json +++ b/worklenz-frontend/public/locales/pt/project-view-finance.json @@ -1,6 +1,6 @@ { "financeText": "Finanças", - "ratecardSingularText": "Tabela de Taxas", + "ratecardSingularText": "Cartão de Taxa", "groupByText": "Agrupar por", "statusText": "Status", "phaseText": "Fase", @@ -8,27 +8,38 @@ "exportButton": "Exportar", "currencyText": "Moeda", "importButton": "Importar", + "filterText": "Filtro", + "billableOnlyText": "Apenas Faturável", + "nonBillableOnlyText": "Apenas Não Faturável", + "allTasksText": "Todas as Tarefas", "taskColumn": "Tarefa", "membersColumn": "Membros", "hoursColumn": "Horas Estimadas", "totalTimeLoggedColumn": "Tempo Total Registrado", - "costColumn": "Custo", + "costColumn": "Custo Real", "estimatedCostColumn": "Custo Estimado", "fixedCostColumn": "Custo Fixo", "totalBudgetedCostColumn": "Custo Total Orçado", - "totalActualCostColumn": "Custo Total Real", - "varianceColumn": "Variação", + "totalActualCostColumn": "Custo Real Total", + "varianceColumn": "Variância", "totalText": "Total", "noTasksFound": "Nenhuma tarefa encontrada", "addRoleButton": "+ Adicionar Função", - "ratecardImportantNotice": "* Esta tabela de taxas é gerada com base nos cargos e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-la de acordo com o projeto. Essas alterações não impactarão os cargos e taxas padrão da organização.", + "ratecardImportantNotice": "* Este cartão de taxa é gerado com base nos títulos de trabalho e taxas padrão da empresa. No entanto, você tem a flexibilidade de modificá-lo de acordo com o projeto. Essas alterações não afetarão os títulos de trabalho e taxas padrão da organização.", "saveButton": "Salvar", - "jobTitleColumn": "Título do Cargo", - "ratePerHourColumn": "Taxa por Hora", - "ratecardPluralText": "Tabelas de Taxas", - "labourHoursColumn": "Horas de Trabalho" + "jobTitleColumn": "Título do Trabalho", + "ratePerHourColumn": "Taxa por hora", + "ratecardPluralText": "Cartões de Taxa", + "labourHoursColumn": "Horas de Trabalho", + "actions": "Ações", + "selectJobTitle": "Selecionar Título do Trabalho", + "ratecardsPluralText": "Modelos de Cartão de Taxa", + "deleteConfirm": "Tem certeza?", + "yes": "Sim", + "no": "Não", + "alreadyImportedRateCardMessage": "Um cartão de taxa já foi importado. Limpe todos os cartões de taxa importados para adicionar um novo." } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json index c7d1e809..9fe81760 100644 --- a/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/pt/settings/ratecard-settings.json @@ -2,19 +2,49 @@ "nameColumn": "Nome", "createdColumn": "Criado", "noProjectsAvailable": "Nenhum projeto disponível", - "deleteConfirmationTitle": "Tem certeza?", - "deleteConfirmationOk": "Sim", + "deleteConfirmationTitle": "Tem certeza que deseja excluir esta tabela de preços?", + "deleteConfirmationOk": "Sim, excluir", "deleteConfirmationCancel": "Cancelar", - "searchPlaceholder": "Pesquisar por nome", + "searchPlaceholder": "Pesquisar tabelas de preços por nome", "createRatecard": "Criar Tabela de Preços", + "editTooltip": "Editar tabela de preços", + "deleteTooltip": "Excluir tabela de preços", + "fetchError": "Falha ao carregar tabelas de preços", + "createError": "Falha ao criar tabela de preços", + "deleteSuccess": "Tabela de preços excluída com sucesso", + "deleteError": "Falha ao excluir tabela de preços", "jobTitleColumn": "Cargo", "ratePerHourColumn": "Taxa por hora", "saveButton": "Salvar", - "addRoleButton": "+ Adicionar Função", - "createRatecardSuccessMessage": "Tabela de Preços criada com sucesso!", - "createRatecardErrorMessage": "Falha ao criar Tabela de Preços!", - "updateRatecardSuccessMessage": "Tabela de Preços atualizada com sucesso!", - "updateRatecardErrorMessage": "Falha ao atualizar Tabela de Preços!", - "currency": "Moeda" + "addRoleButton": "Adicionar Cargo", + "createRatecardSuccessMessage": "Tabela de preços criada com sucesso", + "createRatecardErrorMessage": "Falha ao criar tabela de preços", + "updateRatecardSuccessMessage": "Tabela de preços atualizada com sucesso", + "updateRatecardErrorMessage": "Falha ao atualizar tabela de preços", + "currency": "Moeda", + "actionsColumn": "Ações", + "addAllButton": "Adicionar Todos", + "removeAllButton": "Remover Todos", + "selectJobTitle": "Selecionar cargo", + "unsavedChangesTitle": "Você tem alterações não salvas", + "unsavedChangesMessage": "Deseja salvar suas alterações antes de sair?", + "unsavedChangesSave": "Salvar", + "unsavedChangesDiscard": "Descartar", + "ratecardNameRequired": "O nome da tabela de preços é obrigatório", + "ratecardNamePlaceholder": "Digite o nome da tabela de preços", + "noRatecardsFound": "Nenhuma tabela de preços encontrada", + "loadingRateCards": "Carregando tabelas de preços...", + "noJobTitlesAvailable": "Nenhum cargo disponível", + "noRolesAdded": "Nenhum cargo adicionado ainda", + "createFirstJobTitle": "Criar Primeiro Cargo", + "jobRolesTitle": "Cargos", + "noJobTitlesMessage": "Por favor, crie cargos primeiro nas configurações de Cargos antes de adicionar funções às tabelas de preços.", + "createNewJobTitle": "Criar Novo Cargo", + "jobTitleNamePlaceholder": "Digite o nome do cargo", + "jobTitleNameRequired": "O nome do cargo é obrigatório", + "jobTitleCreatedSuccess": "Cargo criado com sucesso", + "jobTitleCreateError": "Falha ao criar cargo", + "createButton": "Criar", + "cancelButton": "Cancelar" } diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts index be199572..18726930 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance.api.service.ts @@ -5,27 +5,38 @@ import { IProjectFinanceResponse, ITaskBreakdownResponse, IProjectFinanceTask } const rootUrl = `${API_BASE_URL}/project-finance`; +type BillableFilterType = 'all' | 'billable' | 'non-billable'; + export const projectFinanceApiService = { getProjectTasks: async ( projectId: string, - groupBy: 'status' | 'priority' | 'phases' = 'status' + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' ): Promise> => { const response = await apiClient.get>( `${rootUrl}/project/${projectId}/tasks`, { - params: { group_by: groupBy } + params: { + group_by: groupBy, + billable_filter: billableFilter + } } ); - console.log(response.data); return response.data; }, getSubTasks: async ( projectId: string, - parentTaskId: string + parentTaskId: string, + billableFilter: BillableFilterType = 'billable' ): Promise> => { const response = await apiClient.get>( - `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks` + `${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`, + { + params: { + billable_filter: billableFilter + } + } ); return response.data; }, @@ -63,12 +74,16 @@ export const projectFinanceApiService = { exportFinanceData: async ( projectId: string, - groupBy: 'status' | 'priority' | 'phases' = 'status' + groupBy: 'status' | 'priority' | 'phases' = 'status', + billableFilter: BillableFilterType = 'billable' ): Promise => { const response = await apiClient.get( `${rootUrl}/project/${projectId}/export`, { - params: { groupBy }, + params: { + groupBy, + billable_filter: billableFilter + }, responseType: 'blob' } ); diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx index f460231a..e124144f 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-form.tsx @@ -5,6 +5,7 @@ import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; import { themeWiseColor } from '@/utils/themeWiseColor'; import { useAuthService } from '@/hooks/useAuth'; import { useSocket } from '@/socket/socketContext'; @@ -12,6 +13,7 @@ import { SocketEvents } from '@/shared/socket-events'; import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response'; import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types'; import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service'; +import { setRefreshTimestamp } from '@/features/project/project.slice'; interface TimeLogFormProps { onCancel: () => void; @@ -29,6 +31,7 @@ const TimeLogForm = ({ const { t } = useTranslation('task-drawer/task-drawer'); const currentSession = useAuthService().getCurrentSession(); const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); const [form] = Form.useForm(); const [formValues, setFormValues] = React.useState<{ date: any; @@ -170,6 +173,9 @@ const TimeLogForm = ({ await taskTimeLogsApiService.create(requestBody); } + // Trigger refresh of finance data + dispatch(setRefreshTimestamp()); + // Call onSubmitSuccess if provided, otherwise just cancel if (onSubmitSuccess) { onSubmitSuccess(); diff --git a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx index d1bcfdaf..81f84085 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/time-log/time-log-item.tsx @@ -13,6 +13,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice'; import TimeLogForm from './time-log-form'; import { useAuthService } from '@/hooks/useAuth'; +import { setRefreshTimestamp } from '@/features/project/project.slice'; type TimeLogItemProps = { log: ITaskLogViewModel; @@ -41,6 +42,9 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => { if (!logId || !selectedTaskId) return; const res = await taskTimeLogsApiService.delete(logId, selectedTaskId); if (res.done) { + // Trigger refresh of finance data + dispatch(setRefreshTimestamp()); + if (onDelete) onDelete(); } }; diff --git a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx index b968f3e3..e4163e0d 100644 --- a/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx +++ b/worklenz-frontend/src/features/finance/ratecard-drawer/ratecard-drawer.tsx @@ -7,8 +7,11 @@ import { deleteRateCard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer import { RatecardType, IJobType } from '@/types/project/ratecard.types'; import { IJobTitlesViewModel } from '@/types/job.types'; import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; -import { DeleteOutlined, ExclamationCircleFilled } from '@ant-design/icons'; +import { DeleteOutlined, ExclamationCircleFilled, PlusOutlined } from '@ant-design/icons'; import { colors } from '@/styles/colors'; +import CreateJobTitlesDrawer from '@/features/settings/job/CreateJobTitlesDrawer'; +import { toggleCreateJobTitleDrawer } from '@/features/settings/job/jobSlice'; +import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies'; interface PaginationType { current: number; @@ -33,7 +36,7 @@ const RatecardDrawer = ({ const [roles, setRoles] = useState([]); const [initialRoles, setInitialRoles] = useState([]); const [initialName, setInitialName] = useState('Untitled Rate Card'); - const [initialCurrency, setInitialCurrency] = useState('USD'); + const [initialCurrency, setInitialCurrency] = useState(DEFAULT_CURRENCY); const [addingRowIndex, setAddingRowIndex] = useState(null); const { t } = useTranslation('settings/ratecard-settings'); const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading); @@ -46,7 +49,7 @@ const RatecardDrawer = ({ const [isAddingRole, setIsAddingRole] = useState(false); const [selectedJobTitleId, setSelectedJobTitleId] = useState(undefined); const [searchQuery, setSearchQuery] = useState(''); - const [currency, setCurrency] = useState('USD'); + const [currency, setCurrency] = useState(DEFAULT_CURRENCY); const [name, setName] = useState('Untitled Rate Card'); const [jobTitles, setJobTitles] = useState({}); const [pagination, setPagination] = useState({ @@ -61,6 +64,8 @@ const RatecardDrawer = ({ const [editingRowIndex, setEditingRowIndex] = useState(null); const [showUnsavedAlert, setShowUnsavedAlert] = useState(false); const [messageApi, contextHolder] = message.useMessage(); + const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false); + const [newJobTitleName, setNewJobTitleName] = useState(''); // Detect changes const hasChanges = useMemo(() => { const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles); @@ -105,8 +110,8 @@ const RatecardDrawer = ({ setInitialRoles(drawerRatecard.jobRolesList || []); setName(drawerRatecard.name || ''); setInitialName(drawerRatecard.name || ''); - setCurrency(drawerRatecard.currency || 'USD'); - setInitialCurrency(drawerRatecard.currency || 'USD'); + setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); + setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY); } }, [drawerRatecard, type]); @@ -129,15 +134,67 @@ const RatecardDrawer = ({ }; const handleAddRole = () => { - const existingIds = new Set(roles.map(r => r.job_title_id)); - const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!)); - if (availableJobTitles && availableJobTitles.length > 0) { - setRoles([...roles, { job_title_id: '', rate: 0 }]); + if (Object.keys(jobTitles).length === 0) { + // Allow inline job title creation + setIsCreatingJobTitle(true); + } else { + // Add a new empty role to the table + const newRole = { + jobtitle: '', + rate_card_id: ratecardId, + job_title_id: '', + rate: 0, + }; + setRoles([...roles, newRole]); setAddingRowIndex(roles.length); setIsAddingRole(true); } }; + const handleCreateJobTitle = async () => { + if (!newJobTitleName.trim()) { + messageApi.warning(t('jobTitleNameRequired') || 'Job title name is required'); + return; + } + + try { + // Create the job title using the API + const response = await jobTitlesApiService.createJobTitle({ + name: newJobTitleName.trim() + }); + + if (response.done) { + // Refresh job titles + await getJobTitles(); + + // Create a new role with the newly created job title + const newRole = { + jobtitle: newJobTitleName.trim(), + rate_card_id: ratecardId, + job_title_id: response.body.id, + rate: 0, + }; + setRoles([...roles, newRole]); + + // Reset creation state + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + + messageApi.success(t('jobTitleCreatedSuccess') || 'Job title created successfully'); + } else { + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + } catch (error) { + console.error('Failed to create job title:', error); + messageApi.error(t('jobTitleCreateError') || 'Failed to create job title'); + } + }; + + const handleCancelJobTitleCreation = () => { + setIsCreatingJobTitle(false); + setNewJobTitleName(''); + }; + const handleDeleteRole = (index: number) => { const updatedRoles = [...roles]; updatedRoles.splice(index, 1); @@ -195,10 +252,10 @@ const RatecardDrawer = ({ } finally { setRoles([]); setName('Untitled Rate Card'); - setCurrency('USD'); + setCurrency(DEFAULT_CURRENCY); setInitialRoles([]); setInitialName('Untitled Rate Card'); - setInitialCurrency('USD'); + setInitialCurrency(DEFAULT_CURRENCY); } } }; @@ -335,10 +392,10 @@ const RatecardDrawer = ({ dispatch(toggleRatecardDrawer()); setRoles([]); setName('Untitled Rate Card'); - setCurrency('USD'); + setCurrency(DEFAULT_CURRENCY); setInitialRoles([]); setInitialName('Untitled Rate Card'); - setInitialCurrency('USD'); + setInitialCurrency(DEFAULT_CURRENCY); setShowUnsavedAlert(false); }; @@ -353,7 +410,7 @@ const RatecardDrawer = ({ {t('currency')} setNewJobTitleName(e.target.value)} + onPressEnter={handleCreateJobTitle} + autoFocus + style={{ width: 200 }} + /> + + + + + ) : ( + + + {Object.keys(jobTitles).length === 0 + ? t('noJobTitlesAvailable') + : t('noRolesAdded')} + + + ), + }} + /> + + - ); }; diff --git a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts index 788ffb17..c74a6d4b 100644 --- a/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts +++ b/worklenz-frontend/src/features/projects/finance/project-finance.slice.ts @@ -5,10 +5,12 @@ import { parseTimeToSeconds } from '@/utils/timeUtils'; type FinanceTabType = 'finance' | 'ratecard'; type GroupTypes = 'status' | 'priority' | 'phases'; +type BillableFilterType = 'all' | 'billable' | 'non-billable'; interface ProjectFinanceState { activeTab: FinanceTabType; activeGroup: GroupTypes; + billableFilter: BillableFilterType; loading: boolean; taskGroups: IProjectFinanceGroup[]; projectRateCards: IProjectRateCard[]; @@ -65,6 +67,7 @@ const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => { const initialState: ProjectFinanceState = { activeTab: 'finance', activeGroup: 'status', + billableFilter: 'billable', loading: false, taskGroups: [], projectRateCards: [], @@ -73,24 +76,24 @@ const initialState: ProjectFinanceState = { export const fetchProjectFinances = createAsyncThunk( 'projectFinances/fetchProjectFinances', - async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => { - const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy); + async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter); return response.body; } ); export const fetchProjectFinancesSilent = createAsyncThunk( 'projectFinances/fetchProjectFinancesSilent', - async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => { - const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy); + async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter); return response.body; } ); export const fetchSubTasks = createAsyncThunk( 'projectFinances/fetchSubTasks', - async ({ projectId, parentTaskId }: { projectId: string; parentTaskId: string }) => { - const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId); + async ({ projectId, parentTaskId, billableFilter }: { projectId: string; parentTaskId: string; billableFilter?: BillableFilterType }) => { + const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId, billableFilter); return { parentTaskId, subTasks: response.body }; } ); @@ -113,6 +116,9 @@ export const projectFinancesSlice = createSlice({ setActiveGroup: (state, action: PayloadAction) => { state.activeGroup = action.payload; }, + setBillableFilter: (state, action: PayloadAction) => { + state.billableFilter = action.payload; + }, updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => { const { taskId, groupId, fixedCost } = action.payload; const group = state.taskGroups.find(g => g.group_id === groupId); @@ -224,6 +230,7 @@ export const projectFinancesSlice = createSlice({ export const { setActiveTab, setActiveGroup, + setBillableFilter, updateTaskFixedCost, updateTaskEstimatedCost, updateTaskTimeLogged, diff --git a/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx b/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx deleted file mode 100644 index 36dc4053..00000000 --- a/worklenz-frontend/src/lib/project/finance-table-wrapper.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { Table } from 'antd'; -import { useTranslation } from 'react-i18next'; -import { financeTableColumns } from './project-view-finance-table-columns'; - -interface IFinanceTableData { - id: string; - name: string; - estimated_hours: number; - estimated_cost: number; - fixed_cost: number; - total_budgeted_cost: number; - total_actual_cost: number; - variance: number; - total_time_logged: number; - assignees: Array<{ - team_member_id: string; - project_member_id: string; - name: string; - avatar_url: string; - }>; -} - -interface FinanceTableWrapperProps { - data: IFinanceTableData[]; - loading?: boolean; -} - -const FinanceTableWrapper: React.FC = ({ data, loading }) => { - const { t } = useTranslation(); - - const columns = financeTableColumns.map(col => ({ - ...col, - title: t(`projectViewFinance.${col.name}`), - dataIndex: col.key, - key: col.key, - width: col.width, - render: col.render || ((value: any) => { - if (col.type === 'hours') { - return value ? value.toFixed(2) : '0.00'; - } - if (col.type === 'currency') { - return value ? `$${value.toFixed(2)}` : '$0.00'; - } - return value; - }) - })); - - return ( - - ); -}; - -export default FinanceTableWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx deleted file mode 100644 index ba663bce..00000000 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table-wrapper.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import { Card, Col, Row } from "antd"; - -import { IProjectFinanceGroup } from "../../../../../types/project/project-finance.types"; -import FinanceTable from "./finance-table/finance-table"; - -interface Props { - activeTablesList: IProjectFinanceGroup[]; - loading: boolean; -} - -export const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { - const { isDarkMode } = useThemeContext(); - - const getTableColor = (table: IProjectFinanceGroup) => { - return isDarkMode ? table.color_code_dark : table.color_code; - }; - - return ( -
- - {activeTablesList.map((table) => ( -
- -
-

{table.group_name}

-
- -
- - ))} - - - ); -}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx index 2da99b06..1c4419aa 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table-wrapper.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useMemo } from 'react'; -import { Flex, InputNumber, Tooltip, Typography, Empty } from 'antd'; +import { Flex, Typography, Empty } from 'antd'; import { themeWiseColor } from '@/utils/themeWiseColor'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useTranslation } from 'react-i18next'; @@ -8,9 +8,7 @@ import { openFinanceDrawer } from '@/features/finance/finance-slice'; import { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns'; import FinanceTable from './finance-table'; import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer'; -import { convertToHoursMinutes, formatHoursToReadable } from '@/utils/format-hours-to-readable'; import { IProjectFinanceGroup } from '@/types/project/project-finance.types'; -import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice'; interface FinanceTableWrapperProps { activeTablesList: IProjectFinanceGroup[]; @@ -35,14 +33,10 @@ const formatSecondsToTimeString = (totalSeconds: number): string => { const FinanceTableWrapper: React.FC = ({ activeTablesList, loading }) => { const [isScrolling, setIsScrolling] = useState(false); - const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null); const { t } = useTranslation('project-view-finance'); const dispatch = useAppDispatch(); - // Get selected task from Redux store - const selectedTask = useAppSelector(state => state.financeReducer.selectedTask); - const onTaskClick = (task: any) => { dispatch(openFinanceDrawer(task)); }; @@ -61,19 +55,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL }; }, []); - // Handle click outside to close editing - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (editingFixedCost && !(event.target as Element)?.closest('.fixed-cost-input')) { - setEditingFixedCost(null); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [editingFixedCost]); const themeMode = useAppSelector(state => state.themeReducer.mode); const currency = useAppSelector(state => state.projectFinances.project?.currency || "").toUpperCase(); @@ -97,7 +79,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL ) => { table.tasks.forEach((task) => { acc.hours += (task.estimated_seconds) || 0; - acc.cost += task.estimated_cost || 0; + acc.cost += ((task.total_actual || 0) - (task.fixed_cost || 0)); acc.fixedCost += task.fixed_cost || 0; acc.totalBudget += task.total_budget || 0; acc.totalActual += task.total_actual || 0; @@ -120,10 +102,7 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL ); }, [taskGroups]); - const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => { - dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 })); - setEditingFixedCost(null); - }; + const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => { switch (columnKey) { @@ -242,7 +221,6 @@ const FinanceTableWrapper: React.FC = ({ activeTablesL diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx index c5f7ee8b..96809d80 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/finance-tab/finance-table/finance-table.tsx @@ -1,4 +1,4 @@ -import { Checkbox, Flex, Input, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; +import { Flex, InputNumber, Skeleton, Tooltip, Typography } from 'antd'; import { useEffect, useMemo, useState, useRef } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import { @@ -13,7 +13,6 @@ import Avatars from '@/components/avatars/avatars'; import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types'; import { updateTaskFixedCostAsync, - updateTaskFixedCost, fetchProjectFinancesSilent, toggleTaskExpansion, fetchSubTasks @@ -21,7 +20,7 @@ import { import { useAppDispatch } from '@/hooks/useAppDispatch'; import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice'; import { useParams } from 'react-router-dom'; -import { parseTimeToSeconds } from '@/utils/timeUtils'; + import { useAuthService } from '@/hooks/useAuth'; import { canEditFixedCost } from '@/utils/finance-permissions'; import './finance-table.css'; @@ -29,17 +28,16 @@ import './finance-table.css'; type FinanceTableProps = { table: IProjectFinanceGroup; loading: boolean; - isScrolling: boolean; onTaskClick: (task: any) => void; }; const FinanceTable = ({ table, loading, - isScrolling, onTaskClick, }: FinanceTableProps) => { const [isCollapse, setIsCollapse] = useState(false); + const [isScrolling, setIsScrolling] = useState(false); const [selectedTask, setSelectedTask] = useState(null); const [editingFixedCostValue, setEditingFixedCostValue] = useState(null); const [tasks, setTasks] = useState(table.tasks); @@ -357,44 +355,6 @@ const FinanceTable = ({ return parts.join(' '); }; - // Calculate totals for the current table - const totals = useMemo(() => { - return tasks.reduce( - (acc, task) => ({ - hours: acc.hours + (task.estimated_seconds || 0), - total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), - estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), - actual_cost_from_logs: acc.actual_cost_from_logs + ((task.total_actual || 0) - (task.fixed_cost || 0)), - fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), - total_budget: acc.total_budget + (task.total_budget || 0), - total_actual: acc.total_actual + (task.total_actual || 0), - variance: acc.variance + (task.variance || 0) - }), - { - hours: 0, - total_time_logged: 0, - estimated_cost: 0, - actual_cost_from_logs: 0, - fixed_cost: 0, - total_budget: 0, - total_actual: 0, - variance: 0 - } - ); - }, [tasks]); - - // Format the totals for display - const formattedTotals = useMemo(() => ({ - hours: formatSecondsToTimeString(totals.hours), - total_time_logged: formatSecondsToTimeString(totals.total_time_logged), - estimated_cost: totals.estimated_cost, - actual_cost_from_logs: totals.actual_cost_from_logs, - fixed_cost: totals.fixed_cost, - total_budget: totals.total_budget, - total_actual: totals.total_actual, - variance: totals.variance - }), [totals]); - // Flatten tasks to include subtasks for rendering const flattenedTasks = useMemo(() => { const flattened: IProjectFinanceTask[] = []; @@ -414,91 +374,142 @@ const FinanceTable = ({ return flattened; }, [tasks]); + // Calculate totals for the current table (only count parent tasks to avoid double counting) + const totals = useMemo(() => { + return tasks.reduce( + (acc, task) => { + // Calculate actual cost from logs (total_actual - fixed_cost) + const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 0); + + return { + hours: acc.hours + (task.estimated_seconds || 0), + total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0), + estimated_cost: acc.estimated_cost + (task.estimated_cost || 0), + actual_cost_from_logs: acc.actual_cost_from_logs + actualCostFromLogs, + fixed_cost: acc.fixed_cost + (task.fixed_cost || 0), + total_budget: acc.total_budget + (task.total_budget || 0), + total_actual: acc.total_actual + (task.total_actual || 0), + variance: acc.variance + (task.variance || 0) + }; + }, + { + hours: 0, + total_time_logged: 0, + estimated_cost: 0, + actual_cost_from_logs: 0, + fixed_cost: 0, + total_budget: 0, + total_actual: 0, + variance: 0 + } + ); + }, [tasks]); + + // Format the totals for display + const formattedTotals = useMemo(() => ({ + hours: formatSecondsToTimeString(totals.hours), + total_time_logged: formatSecondsToTimeString(totals.total_time_logged), + estimated_cost: totals.estimated_cost, + actual_cost_from_logs: totals.actual_cost_from_logs, + fixed_cost: totals.fixed_cost, + total_budget: totals.total_budget, + total_actual: totals.total_actual, + variance: totals.variance + }), [totals]); + + if (loading) { + return ( + + + + ); + } + return ( - - <> - {/* header row */} + <> + {/* header row */} + + {financeTableColumns.map( + (col, index) => ( + + ) + )} + + + {/* task rows */} + {!isCollapse && flattenedTasks.map((task, idx) => ( e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)} + onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)} > - {financeTableColumns.map( - (col, index) => ( - - ) - )} + {financeTableColumns.map((col) => ( + + ))} - - {/* task rows */} - {!isCollapse && flattenedTasks.map((task, idx) => ( - e.currentTarget.style.background = themeWiseColor('#f0f0f0', '#333', themeMode)} - onMouseLeave={e => e.currentTarget.style.background = idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)} - > - {financeTableColumns.map((col) => ( - - ))} - - ))} - - + ))} + ); }; diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx index f499692f..6d0d9355 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/project-view-finance.tsx @@ -1,11 +1,11 @@ -import { Button, ConfigProvider, Flex, Select, Typography, message, Alert } from 'antd'; -import { useEffect, useState } from 'react'; +import { Button, ConfigProvider, Flex, Select, Typography, message, Alert, Card, Row, Col, Statistic } from 'antd'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CaretDownFilled, DownOutlined } from '@ant-design/icons'; +import { CaretDownFilled, DownOutlined, CalculatorOutlined } from '@ant-design/icons'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; -import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency } from '@/features/projects/finance/project-finance.slice'; +import { fetchProjectFinances, setActiveTab, setActiveGroup, updateProjectFinanceCurrency, fetchProjectFinancesSilent, setBillableFilter } from '@/features/projects/finance/project-finance.slice'; import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice'; import { updateProjectCurrency } from '@/features/project/project.slice'; import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service'; @@ -16,6 +16,8 @@ import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-rat import { useAuthService } from '@/hooks/useAuth'; import { hasFinanceEditPermission } from '@/utils/finance-permissions'; import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; const ProjectViewFinance = () => { const { projectId } = useParams<{ projectId: string }>(); @@ -23,8 +25,9 @@ const ProjectViewFinance = () => { const { t } = useTranslation('project-view-finance'); const [exporting, setExporting] = useState(false); const [updatingCurrency, setUpdatingCurrency] = useState(false); + const { socket } = useSocket(); - const { activeTab, activeGroup, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances); + const { activeTab, activeGroup, billableFilter, loading, taskGroups, project: financeProject } = useAppSelector((state: RootState) => state.projectFinances); const { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer); const phaseList = useAppSelector((state) => state.phaseReducer.phaseList); @@ -39,11 +42,99 @@ const ProjectViewFinance = () => { // Show loading state for currency selector until finance data is loaded const currencyLoading = loading || updatingCurrency || !financeProject; + // Calculate project budget statistics + const budgetStatistics = useMemo(() => { + if (!taskGroups || taskGroups.length === 0) { + return { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0, + budgetUtilization: 0 + }; + } + + const totals = taskGroups.reduce((acc, group) => { + group.tasks.forEach(task => { + acc.totalEstimatedCost += task.estimated_cost || 0; + acc.totalFixedCost += task.fixed_cost || 0; + acc.totalBudget += task.total_budget || 0; + acc.totalActualCost += task.total_actual || 0; + acc.totalVariance += task.variance || 0; + }); + return acc; + }, { + totalEstimatedCost: 0, + totalFixedCost: 0, + totalBudget: 0, + totalActualCost: 0, + totalVariance: 0 + }); + + const budgetUtilization = totals.totalBudget > 0 + ? (totals.totalActualCost / totals.totalBudget) * 100 + : 0; + + return { + ...totals, + budgetUtilization + }; + }, [taskGroups]); + + // Silent refresh function for socket events + const refreshFinanceData = useCallback(() => { + if (projectId) { + dispatch(fetchProjectFinancesSilent({ projectId, groupBy: activeGroup, billableFilter })); + } + }, [projectId, activeGroup, billableFilter, dispatch]); + + // Socket event handlers + const handleTaskEstimationChange = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskTimerStop = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskProgressUpdate = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + + const handleTaskBillableChange = useCallback(() => { + refreshFinanceData(); + }, [refreshFinanceData]); + useEffect(() => { if (projectId) { - dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup })); + dispatch(fetchProjectFinances({ projectId, groupBy: activeGroup, billableFilter })); } - }, [projectId, activeGroup, dispatch, refreshTimestamp]); + }, [projectId, activeGroup, billableFilter, dispatch, refreshTimestamp]); + + // Socket event listeners for finance data refresh + useEffect(() => { + if (!socket) return; + + const eventHandlers = [ + { event: SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handler: handleTaskEstimationChange }, + { event: SocketEvents.TASK_TIMER_STOP.toString(), handler: handleTaskTimerStop }, + { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdate }, + { event: SocketEvents.TASK_BILLABLE_CHANGE.toString(), handler: handleTaskBillableChange }, + ]; + + // Register all event listeners + eventHandlers.forEach(({ event, handler }) => { + socket.on(event, handler); + }); + + // Cleanup function + return () => { + eventHandlers.forEach(({ event, handler }) => { + socket.off(event, handler); + }); + }; + }, [socket, handleTaskEstimationChange, handleTaskTimerStop, handleTaskProgressUpdate, handleTaskBillableChange]); const handleExport = async () => { if (!projectId) { @@ -53,7 +144,7 @@ const ProjectViewFinance = () => { try { setExporting(true); - const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup); + const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup, billableFilter); const projectName = project?.name || 'Unknown_Project'; const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_'); @@ -115,6 +206,12 @@ const ProjectViewFinance = () => { }, ]; + const billableFilterOptions = [ + { key: 'billable', value: 'billable', label: t('billableOnlyText') }, + { key: 'non-billable', value: 'non-billable', label: t('nonBillableOnlyText') }, + { key: 'all', value: 'all', label: t('allTasksText') }, + ]; + return ( {/* Finance Header */} @@ -137,14 +234,26 @@ const ProjectViewFinance = () => { {activeTab === 'finance' && ( - - {t('groupByText')}: - dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))} + suffixIcon={} + /> + + + {t('filterText')}: + setSearchQuery(e.target.value)} - placeholder={t('searchPlaceholder')} - style={{ maxWidth: 232 }} - suffix={} - /> - - - } - > -
+ +
setIsCollapse((prev) => !prev) : undefined} + > + {col.key === FinanceTableColumnKeys.TASK ? ( + + {isCollapse ? : } + {table.group_name} ({tasks.length}) + + ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)} +
setIsCollapse((prev) => !prev) : undefined} - > - {col.key === FinanceTableColumnKeys.TASK ? ( - - {isCollapse ? : } - {table.group_name} ({tasks.length}) - - ) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)} - e.stopPropagation() + : undefined + } + > + {renderFinancialTableColumnContent(col.key, task)} +
e.stopPropagation() - : undefined - } - > - {renderFinancialTableColumnContent(col.key, task)} -
setPagination(prev => ({ ...prev, current: page, pageSize })), - }} - onChange={handleTableChange} - rowClassName="group" - /> - - + <> + {contextHolder} + + setSearchQuery(e.target.value)} + placeholder={t('searchPlaceholder')} + style={{ maxWidth: 232 }} + suffix={} + /> + + + } + > +
setPagination(prev => ({ ...prev, current: page, pageSize })), + }} + onChange={handleTableChange} + rowClassName="group" + locale={{ + emptyText: , + }} + /> + + + ); };