feat(project-finance): add billable filter functionality to project finance queries
- Introduced a `billable_filter` query parameter to filter tasks based on their billable status (billable, non-billable, or all). - Updated the project finance controller to construct SQL queries with billable conditions based on the filter. - Enhanced the frontend components to support billable filtering in project finance views and exports. - Added corresponding translations for filter options in multiple languages. - Refactored related API services to accommodate the new filtering logic.
This commit is contained in:
@@ -50,6 +50,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
): Promise<IWorkLenzResponse> {
|
): Promise<IWorkLenzResponse> {
|
||||||
const projectId = req.params.project_id;
|
const projectId = req.params.project_id;
|
||||||
const groupBy = req.query.group_by || "status";
|
const groupBy = req.query.group_by || "status";
|
||||||
|
const billableFilter = req.query.billable_filter || "billable";
|
||||||
|
|
||||||
// Get project information including currency
|
// Get project information including currency
|
||||||
const projectQuery = `
|
const projectQuery = `
|
||||||
@@ -82,6 +83,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
const rateCardResult = await db.query(rateCardQuery, [projectId]);
|
const rateCardResult = await db.query(rateCardQuery, [projectId]);
|
||||||
const projectRateCards = rateCardResult.rows;
|
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
|
// Get tasks with their financial data - support hierarchical loading
|
||||||
const q = `
|
const q = `
|
||||||
WITH RECURSIVE task_tree AS (
|
WITH RECURSIVE task_tree AS (
|
||||||
@@ -106,6 +115,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHERE t.project_id = $1
|
WHERE t.project_id = $1
|
||||||
AND t.archived = false
|
AND t.archived = false
|
||||||
AND t.parent_task_id IS NULL -- Only load parent tasks initially
|
AND t.parent_task_id IS NULL -- Only load parent tasks initially
|
||||||
|
${billableCondition}
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
@@ -579,6 +589,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
): Promise<IWorkLenzResponse> {
|
): Promise<IWorkLenzResponse> {
|
||||||
const projectId = req.params.project_id;
|
const projectId = req.params.project_id;
|
||||||
const parentTaskId = req.params.parent_task_id;
|
const parentTaskId = req.params.parent_task_id;
|
||||||
|
const billableFilter = req.query.billable_filter || "billable";
|
||||||
|
|
||||||
if (!parentTaskId) {
|
if (!parentTaskId) {
|
||||||
return res
|
return res
|
||||||
@@ -586,6 +597,14 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
.send(new ServerResponse(false, null, "Parent task ID is required"));
|
.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
|
// Get subtasks with their financial data
|
||||||
const q = `
|
const q = `
|
||||||
WITH task_costs AS (
|
WITH task_costs AS (
|
||||||
@@ -607,6 +626,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHERE t.project_id = $1
|
WHERE t.project_id = $1
|
||||||
AND t.archived = false
|
AND t.archived = false
|
||||||
AND t.parent_task_id = $2
|
AND t.parent_task_id = $2
|
||||||
|
${billableCondition}
|
||||||
),
|
),
|
||||||
task_estimated_costs AS (
|
task_estimated_costs AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -721,6 +741,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const projectId = req.params.project_id;
|
const projectId = req.params.project_id;
|
||||||
const groupBy = (req.query.groupBy as string) || "status";
|
const groupBy = (req.query.groupBy as string) || "status";
|
||||||
|
const billableFilter = req.query.billable_filter || "billable";
|
||||||
|
|
||||||
// Get project name and currency for filename and export
|
// Get project name and currency for filename and export
|
||||||
const projectQuery = `SELECT name, currency FROM projects WHERE id = $1`;
|
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 rateCardResult = await db.query(rateCardQuery, [projectId]);
|
||||||
const projectRateCards = rateCardResult.rows;
|
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
|
// Get tasks with their financial data - support hierarchical loading
|
||||||
const q = `
|
const q = `
|
||||||
WITH RECURSIVE task_tree AS (
|
WITH RECURSIVE task_tree AS (
|
||||||
@@ -770,6 +799,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
|||||||
WHERE t.project_id = $1
|
WHERE t.project_id = $1
|
||||||
AND t.archived = false
|
AND t.archived = false
|
||||||
AND t.parent_task_id IS NULL -- Only load parent tasks initially
|
AND t.parent_task_id IS NULL -- Only load parent tasks initially
|
||||||
|
${billableCondition}
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@
|
|||||||
"exportButton": "Export",
|
"exportButton": "Export",
|
||||||
"currencyText": "Currency",
|
"currencyText": "Currency",
|
||||||
"importButton": "Import",
|
"importButton": "Import",
|
||||||
|
"filterText": "Filter",
|
||||||
|
"billableOnlyText": "Billable Only",
|
||||||
|
"nonBillableOnlyText": "Non-Billable Only",
|
||||||
|
"allTasksText": "All Tasks",
|
||||||
|
|
||||||
"taskColumn": "Task",
|
"taskColumn": "Task",
|
||||||
"membersColumn": "Members",
|
"membersColumn": "Members",
|
||||||
"hoursColumn": "Estimated Hours",
|
"hoursColumn": "Estimated Hours",
|
||||||
"totalTimeLoggedColumn": "Total Time Logged",
|
"totalTimeLoggedColumn": "Total Time Logged",
|
||||||
"costColumn": "Cost",
|
"costColumn": "Actual Cost",
|
||||||
"estimatedCostColumn": "Estimated Cost",
|
"estimatedCostColumn": "Estimated Cost",
|
||||||
"fixedCostColumn": "Fixed Cost",
|
"fixedCostColumn": "Fixed Cost",
|
||||||
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||||
|
|||||||
@@ -2,27 +2,49 @@
|
|||||||
"nameColumn": "Name",
|
"nameColumn": "Name",
|
||||||
"createdColumn": "Created",
|
"createdColumn": "Created",
|
||||||
"noProjectsAvailable": "No projects available",
|
"noProjectsAvailable": "No projects available",
|
||||||
"deleteConfirmationTitle": "Are you sure?",
|
"deleteConfirmationTitle": "Are you sure you want to delete this rate card?",
|
||||||
"deleteConfirmationOk": "Yes",
|
"deleteConfirmationOk": "Yes, delete",
|
||||||
"deleteConfirmationCancel": "Cancel",
|
"deleteConfirmationCancel": "Cancel",
|
||||||
"searchPlaceholder": "Search by name",
|
"searchPlaceholder": "Search rate cards by name",
|
||||||
"createRatecard": "Create Rate Card",
|
"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",
|
"jobTitleColumn": "Job title",
|
||||||
"ratePerHourColumn": "Rate per hour",
|
"ratePerHourColumn": "Rate per hour",
|
||||||
"saveButton": "Save",
|
"saveButton": "Save",
|
||||||
"addRoleButton": "+ Add Role",
|
"addRoleButton": "Add Role",
|
||||||
"createRatecardSuccessMessage": "Create Rate Card success!",
|
"createRatecardSuccessMessage": "Rate card created successfully",
|
||||||
"createRatecardErrorMessage": "Create Rate Card failed!",
|
"createRatecardErrorMessage": "Failed to create rate card",
|
||||||
"updateRatecardSuccessMessage": "Update Rate Card success!",
|
"updateRatecardSuccessMessage": "Rate card updated successfully",
|
||||||
"updateRatecardErrorMessage": "Update Rate Card failed!",
|
"updateRatecardErrorMessage": "Failed to update rate card",
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"actionsColumn": "Actions",
|
"actionsColumn": "Actions",
|
||||||
"addAllButton": "Add All",
|
"addAllButton": "Add All",
|
||||||
"removeAllButton": "Remove All",
|
"removeAllButton": "Remove All",
|
||||||
"selectJobTitle": "Select job title",
|
"selectJobTitle": "Select job title",
|
||||||
"unsavedChangesTitle": "Unsaved changes",
|
"unsavedChangesTitle": "You have unsaved changes",
|
||||||
"ratecardNameRequired": "Rate card name is required"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,27 +8,38 @@
|
|||||||
"exportButton": "Exportar",
|
"exportButton": "Exportar",
|
||||||
"currencyText": "Moneda",
|
"currencyText": "Moneda",
|
||||||
"importButton": "Importar",
|
"importButton": "Importar",
|
||||||
|
"filterText": "Filtro",
|
||||||
|
"billableOnlyText": "Solo Facturable",
|
||||||
|
"nonBillableOnlyText": "Solo No Facturable",
|
||||||
|
"allTasksText": "Todas las Tareas",
|
||||||
|
|
||||||
"taskColumn": "Tarea",
|
"taskColumn": "Tarea",
|
||||||
"membersColumn": "Miembros",
|
"membersColumn": "Miembros",
|
||||||
"hoursColumn": "Horas Estimadas",
|
"hoursColumn": "Horas Estimadas",
|
||||||
"totalTimeLoggedColumn": "Tiempo Total Registrado",
|
"totalTimeLoggedColumn": "Tiempo Total Registrado",
|
||||||
"costColumn": "Costo",
|
"costColumn": "Costo Real",
|
||||||
"estimatedCostColumn": "Costo Estimado",
|
"estimatedCostColumn": "Costo Estimado",
|
||||||
"fixedCostColumn": "Costo Fijo",
|
"fixedCostColumn": "Costo Fijo",
|
||||||
"totalBudgetedCostColumn": "Costo Total Presupuestado",
|
"totalBudgetedCostColumn": "Costo Total Presupuestado",
|
||||||
"totalActualCostColumn": "Costo Total Real",
|
"totalActualCostColumn": "Costo Real Total",
|
||||||
"varianceColumn": "Diferencia",
|
"varianceColumn": "Varianza",
|
||||||
"totalText": "Total",
|
"totalText": "Total",
|
||||||
"noTasksFound": "No se encontraron tareas",
|
"noTasksFound": "No se encontraron tareas",
|
||||||
|
|
||||||
"addRoleButton": "+ Agregar Rol",
|
"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",
|
"saveButton": "Guardar",
|
||||||
|
|
||||||
"jobTitleColumn": "Título del Trabajo",
|
"jobTitleColumn": "Título del Trabajo",
|
||||||
"ratePerHourColumn": "Tarifa por hora",
|
"ratePerHourColumn": "Tarifa por hora",
|
||||||
"ratecardPluralText": "Tarifas",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,19 +2,49 @@
|
|||||||
"nameColumn": "Nombre",
|
"nameColumn": "Nombre",
|
||||||
"createdColumn": "Creado",
|
"createdColumn": "Creado",
|
||||||
"noProjectsAvailable": "No hay proyectos disponibles",
|
"noProjectsAvailable": "No hay proyectos disponibles",
|
||||||
"deleteConfirmationTitle": "¿Estás seguro?",
|
"deleteConfirmationTitle": "¿Está seguro de que desea eliminar esta tarjeta de tarifas?",
|
||||||
"deleteConfirmationOk": "Sí",
|
"deleteConfirmationOk": "Sí, eliminar",
|
||||||
"deleteConfirmationCancel": "Cancelar",
|
"deleteConfirmationCancel": "Cancelar",
|
||||||
"searchPlaceholder": "Buscar por nombre",
|
"searchPlaceholder": "Buscar tarjetas de tarifas por nombre",
|
||||||
"createRatecard": "Crear Tarifa",
|
"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",
|
"ratePerHourColumn": "Tarifa por hora",
|
||||||
"saveButton": "Guardar",
|
"saveButton": "Guardar",
|
||||||
"addRoleButton": "+ Agregar Rol",
|
"addRoleButton": "Añadir Rol",
|
||||||
"createRatecardSuccessMessage": "¡Tarifa creada con éxito!",
|
"createRatecardSuccessMessage": "Tarjeta de tarifas creada con éxito",
|
||||||
"createRatecardErrorMessage": "¡Error al crear la tarifa!",
|
"createRatecardErrorMessage": "Error al crear la tarjeta de tarifas",
|
||||||
"updateRatecardSuccessMessage": "¡Tarifa actualizada con éxito!",
|
"updateRatecardSuccessMessage": "Tarjeta de tarifas actualizada con éxito",
|
||||||
"updateRatecardErrorMessage": "¡Error al actualizar la tarifa!",
|
"updateRatecardErrorMessage": "Error al actualizar la tarjeta de tarifas",
|
||||||
"currency": "Moneda"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"financeText": "Finanças",
|
"financeText": "Finanças",
|
||||||
"ratecardSingularText": "Tabela de Taxas",
|
"ratecardSingularText": "Cartão de Taxa",
|
||||||
"groupByText": "Agrupar por",
|
"groupByText": "Agrupar por",
|
||||||
"statusText": "Status",
|
"statusText": "Status",
|
||||||
"phaseText": "Fase",
|
"phaseText": "Fase",
|
||||||
@@ -8,27 +8,38 @@
|
|||||||
"exportButton": "Exportar",
|
"exportButton": "Exportar",
|
||||||
"currencyText": "Moeda",
|
"currencyText": "Moeda",
|
||||||
"importButton": "Importar",
|
"importButton": "Importar",
|
||||||
|
"filterText": "Filtro",
|
||||||
|
"billableOnlyText": "Apenas Faturável",
|
||||||
|
"nonBillableOnlyText": "Apenas Não Faturável",
|
||||||
|
"allTasksText": "Todas as Tarefas",
|
||||||
|
|
||||||
"taskColumn": "Tarefa",
|
"taskColumn": "Tarefa",
|
||||||
"membersColumn": "Membros",
|
"membersColumn": "Membros",
|
||||||
"hoursColumn": "Horas Estimadas",
|
"hoursColumn": "Horas Estimadas",
|
||||||
"totalTimeLoggedColumn": "Tempo Total Registrado",
|
"totalTimeLoggedColumn": "Tempo Total Registrado",
|
||||||
"costColumn": "Custo",
|
"costColumn": "Custo Real",
|
||||||
"estimatedCostColumn": "Custo Estimado",
|
"estimatedCostColumn": "Custo Estimado",
|
||||||
"fixedCostColumn": "Custo Fixo",
|
"fixedCostColumn": "Custo Fixo",
|
||||||
"totalBudgetedCostColumn": "Custo Total Orçado",
|
"totalBudgetedCostColumn": "Custo Total Orçado",
|
||||||
"totalActualCostColumn": "Custo Total Real",
|
"totalActualCostColumn": "Custo Real Total",
|
||||||
"varianceColumn": "Variação",
|
"varianceColumn": "Variância",
|
||||||
"totalText": "Total",
|
"totalText": "Total",
|
||||||
"noTasksFound": "Nenhuma tarefa encontrada",
|
"noTasksFound": "Nenhuma tarefa encontrada",
|
||||||
|
|
||||||
"addRoleButton": "+ Adicionar Função",
|
"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",
|
"saveButton": "Salvar",
|
||||||
|
|
||||||
"jobTitleColumn": "Título do Cargo",
|
"jobTitleColumn": "Título do Trabalho",
|
||||||
"ratePerHourColumn": "Taxa por Hora",
|
"ratePerHourColumn": "Taxa por hora",
|
||||||
"ratecardPluralText": "Tabelas de Taxas",
|
"ratecardPluralText": "Cartões de Taxa",
|
||||||
"labourHoursColumn": "Horas de Trabalho"
|
"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."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,19 +2,49 @@
|
|||||||
"nameColumn": "Nome",
|
"nameColumn": "Nome",
|
||||||
"createdColumn": "Criado",
|
"createdColumn": "Criado",
|
||||||
"noProjectsAvailable": "Nenhum projeto disponível",
|
"noProjectsAvailable": "Nenhum projeto disponível",
|
||||||
"deleteConfirmationTitle": "Tem certeza?",
|
"deleteConfirmationTitle": "Tem certeza que deseja excluir esta tabela de preços?",
|
||||||
"deleteConfirmationOk": "Sim",
|
"deleteConfirmationOk": "Sim, excluir",
|
||||||
"deleteConfirmationCancel": "Cancelar",
|
"deleteConfirmationCancel": "Cancelar",
|
||||||
"searchPlaceholder": "Pesquisar por nome",
|
"searchPlaceholder": "Pesquisar tabelas de preços por nome",
|
||||||
"createRatecard": "Criar Tabela de Preços",
|
"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",
|
"jobTitleColumn": "Cargo",
|
||||||
"ratePerHourColumn": "Taxa por hora",
|
"ratePerHourColumn": "Taxa por hora",
|
||||||
"saveButton": "Salvar",
|
"saveButton": "Salvar",
|
||||||
"addRoleButton": "+ Adicionar Função",
|
"addRoleButton": "Adicionar Cargo",
|
||||||
"createRatecardSuccessMessage": "Tabela de Preços criada com sucesso!",
|
"createRatecardSuccessMessage": "Tabela de preços criada com sucesso",
|
||||||
"createRatecardErrorMessage": "Falha ao criar Tabela de Preços!",
|
"createRatecardErrorMessage": "Falha ao criar tabela de preços",
|
||||||
"updateRatecardSuccessMessage": "Tabela de Preços atualizada com sucesso!",
|
"updateRatecardSuccessMessage": "Tabela de preços atualizada com sucesso",
|
||||||
"updateRatecardErrorMessage": "Falha ao atualizar Tabela de Preços!",
|
"updateRatecardErrorMessage": "Falha ao atualizar tabela de preços",
|
||||||
"currency": "Moeda"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,38 @@ import { IProjectFinanceResponse, ITaskBreakdownResponse, IProjectFinanceTask }
|
|||||||
|
|
||||||
const rootUrl = `${API_BASE_URL}/project-finance`;
|
const rootUrl = `${API_BASE_URL}/project-finance`;
|
||||||
|
|
||||||
|
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||||
|
|
||||||
export const projectFinanceApiService = {
|
export const projectFinanceApiService = {
|
||||||
getProjectTasks: async (
|
getProjectTasks: async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
groupBy: 'status' | 'priority' | 'phases' = 'status'
|
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||||
|
billableFilter: BillableFilterType = 'billable'
|
||||||
): Promise<IServerResponse<IProjectFinanceResponse>> => {
|
): Promise<IServerResponse<IProjectFinanceResponse>> => {
|
||||||
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
|
const response = await apiClient.get<IServerResponse<IProjectFinanceResponse>>(
|
||||||
`${rootUrl}/project/${projectId}/tasks`,
|
`${rootUrl}/project/${projectId}/tasks`,
|
||||||
{
|
{
|
||||||
params: { group_by: groupBy }
|
params: {
|
||||||
|
group_by: groupBy,
|
||||||
|
billable_filter: billableFilter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log(response.data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getSubTasks: async (
|
getSubTasks: async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
parentTaskId: string
|
parentTaskId: string,
|
||||||
|
billableFilter: BillableFilterType = 'billable'
|
||||||
): Promise<IServerResponse<IProjectFinanceTask[]>> => {
|
): Promise<IServerResponse<IProjectFinanceTask[]>> => {
|
||||||
const response = await apiClient.get<IServerResponse<IProjectFinanceTask[]>>(
|
const response = await apiClient.get<IServerResponse<IProjectFinanceTask[]>>(
|
||||||
`${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`
|
`${rootUrl}/project/${projectId}/tasks/${parentTaskId}/subtasks`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
billable_filter: billableFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -63,12 +74,16 @@ export const projectFinanceApiService = {
|
|||||||
|
|
||||||
exportFinanceData: async (
|
exportFinanceData: async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
groupBy: 'status' | 'priority' | 'phases' = 'status'
|
groupBy: 'status' | 'priority' | 'phases' = 'status',
|
||||||
|
billableFilter: BillableFilterType = 'billable'
|
||||||
): Promise<Blob> => {
|
): Promise<Blob> => {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`${rootUrl}/project/${projectId}/export`,
|
`${rootUrl}/project/${projectId}/export`,
|
||||||
{
|
{
|
||||||
params: { groupBy },
|
params: {
|
||||||
|
groupBy,
|
||||||
|
billable_filter: billableFilter
|
||||||
|
},
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
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 { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||||
|
import { setRefreshTimestamp } from '@/features/project/project.slice';
|
||||||
|
|
||||||
interface TimeLogFormProps {
|
interface TimeLogFormProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@@ -29,6 +31,7 @@ const TimeLogForm = ({
|
|||||||
const { t } = useTranslation('task-drawer/task-drawer');
|
const { t } = useTranslation('task-drawer/task-drawer');
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [formValues, setFormValues] = React.useState<{
|
const [formValues, setFormValues] = React.useState<{
|
||||||
date: any;
|
date: any;
|
||||||
@@ -170,6 +173,9 @@ const TimeLogForm = ({
|
|||||||
await taskTimeLogsApiService.create(requestBody);
|
await taskTimeLogsApiService.create(requestBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger refresh of finance data
|
||||||
|
dispatch(setRefreshTimestamp());
|
||||||
|
|
||||||
// Call onSubmitSuccess if provided, otherwise just cancel
|
// Call onSubmitSuccess if provided, otherwise just cancel
|
||||||
if (onSubmitSuccess) {
|
if (onSubmitSuccess) {
|
||||||
onSubmitSuccess();
|
onSubmitSuccess();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|||||||
import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice';
|
import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice';
|
||||||
import TimeLogForm from './time-log-form';
|
import TimeLogForm from './time-log-form';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { setRefreshTimestamp } from '@/features/project/project.slice';
|
||||||
|
|
||||||
type TimeLogItemProps = {
|
type TimeLogItemProps = {
|
||||||
log: ITaskLogViewModel;
|
log: ITaskLogViewModel;
|
||||||
@@ -41,6 +42,9 @@ const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
|
|||||||
if (!logId || !selectedTaskId) return;
|
if (!logId || !selectedTaskId) return;
|
||||||
const res = await taskTimeLogsApiService.delete(logId, selectedTaskId);
|
const res = await taskTimeLogsApiService.delete(logId, selectedTaskId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
|
// Trigger refresh of finance data
|
||||||
|
dispatch(setRefreshTimestamp());
|
||||||
|
|
||||||
if (onDelete) onDelete();
|
if (onDelete) onDelete();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import { deleteRateCard, fetchRateCardById, fetchRateCards, toggleRatecardDrawer
|
|||||||
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
|
import { RatecardType, IJobType } from '@/types/project/ratecard.types';
|
||||||
import { IJobTitlesViewModel } from '@/types/job.types';
|
import { IJobTitlesViewModel } from '@/types/job.types';
|
||||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
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 { 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 {
|
interface PaginationType {
|
||||||
current: number;
|
current: number;
|
||||||
@@ -33,7 +36,7 @@ const RatecardDrawer = ({
|
|||||||
const [roles, setRoles] = useState<IJobType[]>([]);
|
const [roles, setRoles] = useState<IJobType[]>([]);
|
||||||
const [initialRoles, setInitialRoles] = useState<IJobType[]>([]);
|
const [initialRoles, setInitialRoles] = useState<IJobType[]>([]);
|
||||||
const [initialName, setInitialName] = useState<string>('Untitled Rate Card');
|
const [initialName, setInitialName] = useState<string>('Untitled Rate Card');
|
||||||
const [initialCurrency, setInitialCurrency] = useState<string>('USD');
|
const [initialCurrency, setInitialCurrency] = useState<string>(DEFAULT_CURRENCY);
|
||||||
const [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
|
const [addingRowIndex, setAddingRowIndex] = useState<number | null>(null);
|
||||||
const { t } = useTranslation('settings/ratecard-settings');
|
const { t } = useTranslation('settings/ratecard-settings');
|
||||||
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
|
const drawerLoading = useAppSelector(state => state.financeReducer.isFinanceDrawerloading);
|
||||||
@@ -46,7 +49,7 @@ const RatecardDrawer = ({
|
|||||||
const [isAddingRole, setIsAddingRole] = useState(false);
|
const [isAddingRole, setIsAddingRole] = useState(false);
|
||||||
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
|
const [selectedJobTitleId, setSelectedJobTitleId] = useState<string | undefined>(undefined);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [currency, setCurrency] = useState('USD');
|
const [currency, setCurrency] = useState(DEFAULT_CURRENCY);
|
||||||
const [name, setName] = useState<string>('Untitled Rate Card');
|
const [name, setName] = useState<string>('Untitled Rate Card');
|
||||||
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
||||||
const [pagination, setPagination] = useState<PaginationType>({
|
const [pagination, setPagination] = useState<PaginationType>({
|
||||||
@@ -61,6 +64,8 @@ const RatecardDrawer = ({
|
|||||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||||
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false);
|
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false);
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [isCreatingJobTitle, setIsCreatingJobTitle] = useState(false);
|
||||||
|
const [newJobTitleName, setNewJobTitleName] = useState('');
|
||||||
// Detect changes
|
// Detect changes
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles);
|
const rolesChanged = JSON.stringify(roles) !== JSON.stringify(initialRoles);
|
||||||
@@ -105,8 +110,8 @@ const RatecardDrawer = ({
|
|||||||
setInitialRoles(drawerRatecard.jobRolesList || []);
|
setInitialRoles(drawerRatecard.jobRolesList || []);
|
||||||
setName(drawerRatecard.name || '');
|
setName(drawerRatecard.name || '');
|
||||||
setInitialName(drawerRatecard.name || '');
|
setInitialName(drawerRatecard.name || '');
|
||||||
setCurrency(drawerRatecard.currency || 'USD');
|
setCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
|
||||||
setInitialCurrency(drawerRatecard.currency || 'USD');
|
setInitialCurrency(drawerRatecard.currency || DEFAULT_CURRENCY);
|
||||||
}
|
}
|
||||||
}, [drawerRatecard, type]);
|
}, [drawerRatecard, type]);
|
||||||
|
|
||||||
@@ -129,15 +134,67 @@ const RatecardDrawer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddRole = () => {
|
const handleAddRole = () => {
|
||||||
const existingIds = new Set(roles.map(r => r.job_title_id));
|
if (Object.keys(jobTitles).length === 0) {
|
||||||
const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!));
|
// Allow inline job title creation
|
||||||
if (availableJobTitles && availableJobTitles.length > 0) {
|
setIsCreatingJobTitle(true);
|
||||||
setRoles([...roles, { job_title_id: '', rate: 0 }]);
|
} 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);
|
setAddingRowIndex(roles.length);
|
||||||
setIsAddingRole(true);
|
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 handleDeleteRole = (index: number) => {
|
||||||
const updatedRoles = [...roles];
|
const updatedRoles = [...roles];
|
||||||
updatedRoles.splice(index, 1);
|
updatedRoles.splice(index, 1);
|
||||||
@@ -195,10 +252,10 @@ const RatecardDrawer = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
setName('Untitled Rate Card');
|
setName('Untitled Rate Card');
|
||||||
setCurrency('USD');
|
setCurrency(DEFAULT_CURRENCY);
|
||||||
setInitialRoles([]);
|
setInitialRoles([]);
|
||||||
setInitialName('Untitled Rate Card');
|
setInitialName('Untitled Rate Card');
|
||||||
setInitialCurrency('USD');
|
setInitialCurrency(DEFAULT_CURRENCY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -335,10 +392,10 @@ const RatecardDrawer = ({
|
|||||||
dispatch(toggleRatecardDrawer());
|
dispatch(toggleRatecardDrawer());
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
setName('Untitled Rate Card');
|
setName('Untitled Rate Card');
|
||||||
setCurrency('USD');
|
setCurrency(DEFAULT_CURRENCY);
|
||||||
setInitialRoles([]);
|
setInitialRoles([]);
|
||||||
setInitialName('Untitled Rate Card');
|
setInitialName('Untitled Rate Card');
|
||||||
setInitialCurrency('USD');
|
setInitialCurrency(DEFAULT_CURRENCY);
|
||||||
setShowUnsavedAlert(false);
|
setShowUnsavedAlert(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,7 +410,7 @@ const RatecardDrawer = ({
|
|||||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
placeholder="Enter rate card name"
|
placeholder={t('ratecardNamePlaceholder')}
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -371,15 +428,11 @@ const RatecardDrawer = ({
|
|||||||
<Typography.Text>{t('currency')}</Typography.Text>
|
<Typography.Text>{t('currency')}</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
value={currency}
|
value={currency}
|
||||||
options={[
|
options={CURRENCY_OPTIONS}
|
||||||
{ value: 'USD', label: 'USD' },
|
|
||||||
{ value: 'LKR', label: 'LKR' },
|
|
||||||
{ value: 'INR', label: 'INR' },
|
|
||||||
]}
|
|
||||||
onChange={(value) => setCurrency(value)}
|
onChange={(value) => setCurrency(value)}
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleAddAllRoles} type="default">
|
<Button onClick={handleAddAllRoles} type="default">
|
||||||
{t('addAllButton') || 'Add All'}
|
{t('addAllButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -414,25 +467,63 @@ const RatecardDrawer = ({
|
|||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Table
|
<Flex vertical gap={16}>
|
||||||
dataSource={roles}
|
<Flex justify="space-between" align="center">
|
||||||
columns={columns}
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
rowKey={(record) => record.job_title_id}
|
{t('jobRolesTitle') || 'Job Roles'}
|
||||||
pagination={false}
|
</Typography.Title>
|
||||||
footer={() => (
|
|
||||||
<Button
|
<Button
|
||||||
type="dashed"
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
onClick={handleAddRole}
|
onClick={handleAddRole}
|
||||||
block
|
|
||||||
style={{ margin: 0, padding: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('addRoleButton')}
|
{t('addRoleButton')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</Flex>
|
||||||
/>
|
|
||||||
</Drawer>
|
|
||||||
</>
|
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={roles}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="job_title_id"
|
||||||
|
pagination={false}
|
||||||
|
locale={{
|
||||||
|
emptyText: isCreatingJobTitle ? (
|
||||||
|
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{t('createNewJobTitle') || 'Create New Job Title'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Input
|
||||||
|
placeholder={t('jobTitleNamePlaceholder') || 'Enter job title name'}
|
||||||
|
value={newJobTitleName}
|
||||||
|
onChange={(e) => setNewJobTitleName(e.target.value)}
|
||||||
|
onPressEnter={handleCreateJobTitle}
|
||||||
|
autoFocus
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={handleCreateJobTitle}>
|
||||||
|
{t('createButton') || 'Create'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCancelJobTitleCreation}>
|
||||||
|
{t('cancelButton') || 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{Object.keys(jobTitles).length === 0
|
||||||
|
? t('noJobTitlesAvailable')
|
||||||
|
: t('noRolesAdded')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Drawer>
|
||||||
|
<CreateJobTitlesDrawer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { parseTimeToSeconds } from '@/utils/timeUtils';
|
|||||||
|
|
||||||
type FinanceTabType = 'finance' | 'ratecard';
|
type FinanceTabType = 'finance' | 'ratecard';
|
||||||
type GroupTypes = 'status' | 'priority' | 'phases';
|
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||||
|
type BillableFilterType = 'all' | 'billable' | 'non-billable';
|
||||||
|
|
||||||
interface ProjectFinanceState {
|
interface ProjectFinanceState {
|
||||||
activeTab: FinanceTabType;
|
activeTab: FinanceTabType;
|
||||||
activeGroup: GroupTypes;
|
activeGroup: GroupTypes;
|
||||||
|
billableFilter: BillableFilterType;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
taskGroups: IProjectFinanceGroup[];
|
taskGroups: IProjectFinanceGroup[];
|
||||||
projectRateCards: IProjectRateCard[];
|
projectRateCards: IProjectRateCard[];
|
||||||
@@ -65,6 +67,7 @@ const calculateGroupTotals = (tasks: IProjectFinanceTask[]) => {
|
|||||||
const initialState: ProjectFinanceState = {
|
const initialState: ProjectFinanceState = {
|
||||||
activeTab: 'finance',
|
activeTab: 'finance',
|
||||||
activeGroup: 'status',
|
activeGroup: 'status',
|
||||||
|
billableFilter: 'billable',
|
||||||
loading: false,
|
loading: false,
|
||||||
taskGroups: [],
|
taskGroups: [],
|
||||||
projectRateCards: [],
|
projectRateCards: [],
|
||||||
@@ -73,24 +76,24 @@ const initialState: ProjectFinanceState = {
|
|||||||
|
|
||||||
export const fetchProjectFinances = createAsyncThunk(
|
export const fetchProjectFinances = createAsyncThunk(
|
||||||
'projectFinances/fetchProjectFinances',
|
'projectFinances/fetchProjectFinances',
|
||||||
async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => {
|
async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => {
|
||||||
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy);
|
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter);
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchProjectFinancesSilent = createAsyncThunk(
|
export const fetchProjectFinancesSilent = createAsyncThunk(
|
||||||
'projectFinances/fetchProjectFinancesSilent',
|
'projectFinances/fetchProjectFinancesSilent',
|
||||||
async ({ projectId, groupBy }: { projectId: string; groupBy: GroupTypes }) => {
|
async ({ projectId, groupBy, billableFilter }: { projectId: string; groupBy: GroupTypes; billableFilter?: BillableFilterType }) => {
|
||||||
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy);
|
const response = await projectFinanceApiService.getProjectTasks(projectId, groupBy, billableFilter);
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchSubTasks = createAsyncThunk(
|
export const fetchSubTasks = createAsyncThunk(
|
||||||
'projectFinances/fetchSubTasks',
|
'projectFinances/fetchSubTasks',
|
||||||
async ({ projectId, parentTaskId }: { projectId: string; parentTaskId: string }) => {
|
async ({ projectId, parentTaskId, billableFilter }: { projectId: string; parentTaskId: string; billableFilter?: BillableFilterType }) => {
|
||||||
const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId);
|
const response = await projectFinanceApiService.getSubTasks(projectId, parentTaskId, billableFilter);
|
||||||
return { parentTaskId, subTasks: response.body };
|
return { parentTaskId, subTasks: response.body };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -113,6 +116,9 @@ export const projectFinancesSlice = createSlice({
|
|||||||
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
|
setActiveGroup: (state, action: PayloadAction<GroupTypes>) => {
|
||||||
state.activeGroup = action.payload;
|
state.activeGroup = action.payload;
|
||||||
},
|
},
|
||||||
|
setBillableFilter: (state, action: PayloadAction<BillableFilterType>) => {
|
||||||
|
state.billableFilter = action.payload;
|
||||||
|
},
|
||||||
updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => {
|
updateTaskFixedCost: (state, action: PayloadAction<{ taskId: string; groupId: string; fixedCost: number }>) => {
|
||||||
const { taskId, groupId, fixedCost } = action.payload;
|
const { taskId, groupId, fixedCost } = action.payload;
|
||||||
const group = state.taskGroups.find(g => g.group_id === groupId);
|
const group = state.taskGroups.find(g => g.group_id === groupId);
|
||||||
@@ -224,6 +230,7 @@ export const projectFinancesSlice = createSlice({
|
|||||||
export const {
|
export const {
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setActiveGroup,
|
setActiveGroup,
|
||||||
|
setBillableFilter,
|
||||||
updateTaskFixedCost,
|
updateTaskFixedCost,
|
||||||
updateTaskEstimatedCost,
|
updateTaskEstimatedCost,
|
||||||
updateTaskTimeLogged,
|
updateTaskTimeLogged,
|
||||||
|
|||||||
@@ -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<FinanceTableWrapperProps> = ({ 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 (
|
|
||||||
<Table
|
|
||||||
dataSource={data}
|
|
||||||
columns={columns}
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
rowKey="id"
|
|
||||||
scroll={{ x: 'max-content' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FinanceTableWrapper;
|
|
||||||
@@ -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<Props> = ({ activeTablesList, loading }) => {
|
|
||||||
const { isDarkMode } = useThemeContext();
|
|
||||||
|
|
||||||
const getTableColor = (table: IProjectFinanceGroup) => {
|
|
||||||
return isDarkMode ? table.color_code_dark : table.color_code;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="finance-table-wrapper">
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
{activeTablesList.map((table) => (
|
|
||||||
<Col key={table.group_id} xs={24} sm={24} md={24} lg={24} xl={24}>
|
|
||||||
<Card
|
|
||||||
className="finance-table-card"
|
|
||||||
style={{
|
|
||||||
borderTop: `3px solid ${getTableColor(table)}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="finance-table-header">
|
|
||||||
<h3>{table.group_name}</h3>
|
|
||||||
</div>
|
|
||||||
<FinanceTable
|
|
||||||
table={table}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
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 { themeWiseColor } from '@/utils/themeWiseColor';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { financeTableColumns, FinanceTableColumnKeys } from '@/lib/project/project-view-finance-table-columns';
|
||||||
import FinanceTable from './finance-table';
|
import FinanceTable from './finance-table';
|
||||||
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
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 { IProjectFinanceGroup } from '@/types/project/project-finance.types';
|
||||||
import { updateTaskFixedCostAsync } from '@/features/projects/finance/project-finance.slice';
|
|
||||||
|
|
||||||
interface FinanceTableWrapperProps {
|
interface FinanceTableWrapperProps {
|
||||||
activeTablesList: IProjectFinanceGroup[];
|
activeTablesList: IProjectFinanceGroup[];
|
||||||
@@ -35,14 +33,10 @@ const formatSecondsToTimeString = (totalSeconds: number): string => {
|
|||||||
|
|
||||||
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesList, loading }) => {
|
||||||
const [isScrolling, setIsScrolling] = useState(false);
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
const [editingFixedCost, setEditingFixedCost] = useState<{ taskId: string; groupId: string } | null>(null);
|
|
||||||
|
|
||||||
const { t } = useTranslation('project-view-finance');
|
const { t } = useTranslation('project-view-finance');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// Get selected task from Redux store
|
|
||||||
const selectedTask = useAppSelector(state => state.financeReducer.selectedTask);
|
|
||||||
|
|
||||||
const onTaskClick = (task: any) => {
|
const onTaskClick = (task: any) => {
|
||||||
dispatch(openFinanceDrawer(task));
|
dispatch(openFinanceDrawer(task));
|
||||||
};
|
};
|
||||||
@@ -61,19 +55,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ 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 themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const currency = useAppSelector(state => state.projectFinances.project?.currency || "").toUpperCase();
|
const currency = useAppSelector(state => state.projectFinances.project?.currency || "").toUpperCase();
|
||||||
@@ -97,7 +79,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
|||||||
) => {
|
) => {
|
||||||
table.tasks.forEach((task) => {
|
table.tasks.forEach((task) => {
|
||||||
acc.hours += (task.estimated_seconds) || 0;
|
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.fixedCost += task.fixed_cost || 0;
|
||||||
acc.totalBudget += task.total_budget || 0;
|
acc.totalBudget += task.total_budget || 0;
|
||||||
acc.totalActual += task.total_actual || 0;
|
acc.totalActual += task.total_actual || 0;
|
||||||
@@ -120,10 +102,7 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
|||||||
);
|
);
|
||||||
}, [taskGroups]);
|
}, [taskGroups]);
|
||||||
|
|
||||||
const handleFixedCostChange = (value: number | null, taskId: string, groupId: string) => {
|
|
||||||
dispatch(updateTaskFixedCostAsync({ taskId, groupId, fixedCost: value || 0 }));
|
|
||||||
setEditingFixedCost(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
const renderFinancialTableHeaderContent = (columnKey: FinanceTableColumnKeys) => {
|
||||||
switch (columnKey) {
|
switch (columnKey) {
|
||||||
@@ -242,7 +221,6 @@ const FinanceTableWrapper: React.FC<FinanceTableWrapperProps> = ({ activeTablesL
|
|||||||
<FinanceTable
|
<FinanceTable
|
||||||
key={table.group_id}
|
key={table.group_id}
|
||||||
table={table}
|
table={table}
|
||||||
isScrolling={isScrolling}
|
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,6 @@ import Avatars from '@/components/avatars/avatars';
|
|||||||
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
import { IProjectFinanceGroup, IProjectFinanceTask } from '@/types/project/project-finance.types';
|
||||||
import {
|
import {
|
||||||
updateTaskFixedCostAsync,
|
updateTaskFixedCostAsync,
|
||||||
updateTaskFixedCost,
|
|
||||||
fetchProjectFinancesSilent,
|
fetchProjectFinancesSilent,
|
||||||
toggleTaskExpansion,
|
toggleTaskExpansion,
|
||||||
fetchSubTasks
|
fetchSubTasks
|
||||||
@@ -21,7 +20,7 @@ import {
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { parseTimeToSeconds } from '@/utils/timeUtils';
|
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { canEditFixedCost } from '@/utils/finance-permissions';
|
import { canEditFixedCost } from '@/utils/finance-permissions';
|
||||||
import './finance-table.css';
|
import './finance-table.css';
|
||||||
@@ -29,17 +28,16 @@ import './finance-table.css';
|
|||||||
type FinanceTableProps = {
|
type FinanceTableProps = {
|
||||||
table: IProjectFinanceGroup;
|
table: IProjectFinanceGroup;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isScrolling: boolean;
|
|
||||||
onTaskClick: (task: any) => void;
|
onTaskClick: (task: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FinanceTable = ({
|
const FinanceTable = ({
|
||||||
table,
|
table,
|
||||||
loading,
|
loading,
|
||||||
isScrolling,
|
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
}: FinanceTableProps) => {
|
}: FinanceTableProps) => {
|
||||||
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
||||||
|
const [isScrolling, setIsScrolling] = useState<boolean>(false);
|
||||||
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<IProjectFinanceTask | null>(null);
|
||||||
const [editingFixedCostValue, setEditingFixedCostValue] = useState<number | null>(null);
|
const [editingFixedCostValue, setEditingFixedCostValue] = useState<number | null>(null);
|
||||||
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
const [tasks, setTasks] = useState<IProjectFinanceTask[]>(table.tasks);
|
||||||
@@ -357,19 +355,43 @@ const FinanceTable = ({
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate totals for the current table
|
// Flatten tasks to include subtasks for rendering
|
||||||
|
const flattenedTasks = useMemo(() => {
|
||||||
|
const flattened: IProjectFinanceTask[] = [];
|
||||||
|
|
||||||
|
tasks.forEach(task => {
|
||||||
|
// Add the parent task
|
||||||
|
flattened.push(task);
|
||||||
|
|
||||||
|
// Add subtasks if they are expanded and loaded
|
||||||
|
if (task.show_sub_tasks && task.sub_tasks) {
|
||||||
|
task.sub_tasks.forEach(subTask => {
|
||||||
|
flattened.push(subTask);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return flattened;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// Calculate totals for the current table (only count parent tasks to avoid double counting)
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return tasks.reduce(
|
return tasks.reduce(
|
||||||
(acc, task) => ({
|
(acc, task) => {
|
||||||
hours: acc.hours + (task.estimated_seconds || 0),
|
// Calculate actual cost from logs (total_actual - fixed_cost)
|
||||||
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
const actualCostFromLogs = (task.total_actual || 0) - (task.fixed_cost || 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)),
|
return {
|
||||||
fixed_cost: acc.fixed_cost + (task.fixed_cost || 0),
|
hours: acc.hours + (task.estimated_seconds || 0),
|
||||||
total_budget: acc.total_budget + (task.total_budget || 0),
|
total_time_logged: acc.total_time_logged + (task.total_time_logged_seconds || 0),
|
||||||
total_actual: acc.total_actual + (task.total_actual || 0),
|
estimated_cost: acc.estimated_cost + (task.estimated_cost || 0),
|
||||||
variance: acc.variance + (task.variance || 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,
|
hours: 0,
|
||||||
total_time_logged: 0,
|
total_time_logged: 0,
|
||||||
@@ -395,110 +417,99 @@ const FinanceTable = ({
|
|||||||
variance: totals.variance
|
variance: totals.variance
|
||||||
}), [totals]);
|
}), [totals]);
|
||||||
|
|
||||||
// Flatten tasks to include subtasks for rendering
|
if (loading) {
|
||||||
const flattenedTasks = useMemo(() => {
|
return (
|
||||||
const flattened: IProjectFinanceTask[] = [];
|
<tr>
|
||||||
|
<td colSpan={financeTableColumns.length}>
|
||||||
tasks.forEach(task => {
|
<Skeleton active />
|
||||||
// Add the parent task
|
</td>
|
||||||
flattened.push(task);
|
</tr>
|
||||||
|
);
|
||||||
// Add subtasks if they are expanded and loaded
|
}
|
||||||
if (task.show_sub_tasks && task.sub_tasks) {
|
|
||||||
task.sub_tasks.forEach(subTask => {
|
|
||||||
flattened.push(subTask);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return flattened;
|
|
||||||
}, [tasks]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Skeleton active loading={loading}>
|
<>
|
||||||
<>
|
{/* header row */}
|
||||||
{/* header row */}
|
<tr
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
table.color_code,
|
||||||
|
table.color_code_dark,
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||||
|
>
|
||||||
|
{financeTableColumns.map(
|
||||||
|
(col, index) => (
|
||||||
|
<td
|
||||||
|
key={`header-${col.key}`}
|
||||||
|
style={{
|
||||||
|
width: col.width,
|
||||||
|
paddingInline: 16,
|
||||||
|
textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
table.color_code,
|
||||||
|
table.color_code_dark,
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
|
||||||
|
textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
|
||||||
|
}}
|
||||||
|
className={customHeaderColumnStyles(col.key)}
|
||||||
|
onClick={col.key === FinanceTableColumnKeys.TASK ? () => setIsCollapse((prev) => !prev) : undefined}
|
||||||
|
>
|
||||||
|
{col.key === FinanceTableColumnKeys.TASK ? (
|
||||||
|
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
||||||
|
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
||||||
|
{table.group_name} ({tasks.length})
|
||||||
|
</Flex>
|
||||||
|
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* task rows */}
|
||||||
|
{!isCollapse && flattenedTasks.map((task, idx) => (
|
||||||
<tr
|
<tr
|
||||||
|
key={task.id}
|
||||||
style={{
|
style={{
|
||||||
height: 40,
|
height: 40,
|
||||||
backgroundColor: themeWiseColor(
|
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
||||||
table.color_code,
|
transition: 'background 0.2s',
|
||||||
table.color_code_dark,
|
|
||||||
themeMode
|
|
||||||
),
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
}}
|
||||||
className={`group ${themeMode === 'dark' ? 'dark' : ''}`}
|
className={themeMode === 'dark' ? 'dark' : ''}
|
||||||
|
onMouseEnter={e => 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(
|
{financeTableColumns.map((col) => (
|
||||||
(col, index) => (
|
<td
|
||||||
<td
|
key={`${task.id}-${col.key}`}
|
||||||
key={`header-${col.key}`}
|
style={{
|
||||||
style={{
|
width: col.width,
|
||||||
width: col.width,
|
paddingInline: 16,
|
||||||
paddingInline: 16,
|
textAlign: col.type === 'string' ? 'left' : 'right',
|
||||||
textAlign: col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS ? 'left' : 'right',
|
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
||||||
backgroundColor: themeWiseColor(
|
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
||||||
table.color_code,
|
'transparent',
|
||||||
table.color_code_dark,
|
cursor: 'default'
|
||||||
themeMode
|
}}
|
||||||
),
|
className={customColumnStyles(col.key)}
|
||||||
cursor: col.key === FinanceTableColumnKeys.TASK ? 'pointer' : 'default',
|
onClick={
|
||||||
textTransform: col.key === FinanceTableColumnKeys.TASK ? 'capitalize' : 'none',
|
col.key === FinanceTableColumnKeys.FIXED_COST
|
||||||
}}
|
? (e) => e.stopPropagation()
|
||||||
className={customHeaderColumnStyles(col.key)}
|
: undefined
|
||||||
onClick={col.key === FinanceTableColumnKeys.TASK ? () => setIsCollapse((prev) => !prev) : undefined}
|
}
|
||||||
>
|
>
|
||||||
{col.key === FinanceTableColumnKeys.TASK ? (
|
{renderFinancialTableColumnContent(col.key, task)}
|
||||||
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
</td>
|
||||||
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
))}
|
||||||
{table.group_name} ({tasks.length})
|
|
||||||
</Flex>
|
|
||||||
) : col.key === FinanceTableColumnKeys.MEMBERS ? null : renderFinancialTableHeaderContent(col.key)}
|
|
||||||
</td>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
|
))}
|
||||||
{/* task rows */}
|
</>
|
||||||
{!isCollapse && flattenedTasks.map((task, idx) => (
|
|
||||||
<tr
|
|
||||||
key={task.id}
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
background: idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode),
|
|
||||||
transition: 'background 0.2s',
|
|
||||||
}}
|
|
||||||
className={themeMode === 'dark' ? 'dark' : ''}
|
|
||||||
onMouseEnter={e => 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) => (
|
|
||||||
<td
|
|
||||||
key={`${task.id}-${col.key}`}
|
|
||||||
style={{
|
|
||||||
width: col.width,
|
|
||||||
paddingInline: 16,
|
|
||||||
textAlign: col.type === 'string' ? 'left' : 'right',
|
|
||||||
backgroundColor: (col.key === FinanceTableColumnKeys.TASK || col.key === FinanceTableColumnKeys.MEMBERS) ?
|
|
||||||
(idx % 2 === 0 ? themeWiseColor('#fafafa', '#232323', themeMode) : themeWiseColor('#ffffff', '#181818', themeMode)) :
|
|
||||||
'transparent',
|
|
||||||
cursor: 'default'
|
|
||||||
}}
|
|
||||||
className={customColumnStyles(col.key)}
|
|
||||||
onClick={
|
|
||||||
col.key === FinanceTableColumnKeys.FIXED_COST
|
|
||||||
? (e) => e.stopPropagation()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderFinancialTableColumnContent(col.key, task)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
</Skeleton>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Button, ConfigProvider, Flex, Select, Typography, message, Alert } from 'antd';
|
import { Button, ConfigProvider, Flex, Select, Typography, message, Alert, Card, Row, Col, Statistic } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
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 { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
|
||||||
import { updateProjectCurrency } from '@/features/project/project.slice';
|
import { updateProjectCurrency } from '@/features/project/project.slice';
|
||||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
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 { useAuthService } from '@/hooks/useAuth';
|
||||||
import { hasFinanceEditPermission } from '@/utils/finance-permissions';
|
import { hasFinanceEditPermission } from '@/utils/finance-permissions';
|
||||||
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies';
|
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from '@/shared/constants/currencies';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
|
||||||
const ProjectViewFinance = () => {
|
const ProjectViewFinance = () => {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -23,8 +25,9 @@ const ProjectViewFinance = () => {
|
|||||||
const { t } = useTranslation('project-view-finance');
|
const { t } = useTranslation('project-view-finance');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [updatingCurrency, setUpdatingCurrency] = 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 { refreshTimestamp, project } = useAppSelector((state: RootState) => state.projectReducer);
|
||||||
const phaseList = useAppSelector((state) => state.phaseReducer.phaseList);
|
const phaseList = useAppSelector((state) => state.phaseReducer.phaseList);
|
||||||
|
|
||||||
@@ -39,11 +42,99 @@ const ProjectViewFinance = () => {
|
|||||||
// Show loading state for currency selector until finance data is loaded
|
// Show loading state for currency selector until finance data is loaded
|
||||||
const currencyLoading = loading || updatingCurrency || !financeProject;
|
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(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
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 () => {
|
const handleExport = async () => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -53,7 +144,7 @@ const ProjectViewFinance = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup);
|
const blob = await projectFinanceApiService.exportFinanceData(projectId, activeGroup, billableFilter);
|
||||||
|
|
||||||
const projectName = project?.name || 'Unknown_Project';
|
const projectName = project?.name || 'Unknown_Project';
|
||||||
const sanitizedProjectName = projectName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_');
|
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 (
|
return (
|
||||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
{/* Finance Header */}
|
{/* Finance Header */}
|
||||||
@@ -137,14 +234,26 @@ const ProjectViewFinance = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{activeTab === 'finance' && (
|
{activeTab === 'finance' && (
|
||||||
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
|
<Flex align="center" gap={16} style={{ marginInlineStart: 12 }}>
|
||||||
{t('groupByText')}:
|
<Flex align="center" gap={4}>
|
||||||
<Select
|
{t('groupByText')}:
|
||||||
value={activeGroup}
|
<Select
|
||||||
options={groupDropdownMenuItems}
|
value={activeGroup}
|
||||||
onChange={(value) => dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))}
|
options={groupDropdownMenuItems}
|
||||||
suffixIcon={<CaretDownFilled />}
|
onChange={(value) => dispatch(setActiveGroup(value as 'status' | 'priority' | 'phases'))}
|
||||||
/>
|
suffixIcon={<CaretDownFilled />}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" gap={4}>
|
||||||
|
{t('filterText')}:
|
||||||
|
<Select
|
||||||
|
value={billableFilter}
|
||||||
|
options={billableFilterOptions}
|
||||||
|
onChange={(value) => dispatch(setBillableFilter(value as 'all' | 'billable' | 'non-billable'))}
|
||||||
|
suffixIcon={<CaretDownFilled />}
|
||||||
|
style={{ minWidth: 140 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -194,6 +303,106 @@ const ProjectViewFinance = () => {
|
|||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Budget Statistics */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<CalculatorOutlined />
|
||||||
|
<Typography.Text strong>Project Budget Overview</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Total Budget"
|
||||||
|
value={budgetStatistics.totalBudget}
|
||||||
|
precision={2}
|
||||||
|
prefix={projectCurrency.toUpperCase()}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Actual Cost"
|
||||||
|
value={budgetStatistics.totalActualCost}
|
||||||
|
precision={2}
|
||||||
|
prefix={projectCurrency.toUpperCase()}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Variance"
|
||||||
|
value={budgetStatistics.totalVariance}
|
||||||
|
precision={2}
|
||||||
|
prefix={budgetStatistics.totalVariance >= 0 ? '+' : ''}
|
||||||
|
suffix={projectCurrency.toUpperCase()}
|
||||||
|
valueStyle={{
|
||||||
|
color: budgetStatistics.totalVariance > 0 ? '#ff4d4f' : '#52c41a'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Budget Utilization"
|
||||||
|
value={budgetStatistics.budgetUtilization}
|
||||||
|
precision={1}
|
||||||
|
suffix="%"
|
||||||
|
valueStyle={{
|
||||||
|
color: budgetStatistics.budgetUtilization > 100 ? '#ff4d4f' :
|
||||||
|
budgetStatistics.budgetUtilization > 80 ? '#faad14' : '#52c41a'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Estimated Cost"
|
||||||
|
value={budgetStatistics.totalEstimatedCost}
|
||||||
|
precision={2}
|
||||||
|
prefix={projectCurrency.toUpperCase()}
|
||||||
|
valueStyle={{ color: '#722ed1' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Fixed Cost"
|
||||||
|
value={budgetStatistics.totalFixedCost}
|
||||||
|
precision={2}
|
||||||
|
prefix={projectCurrency.toUpperCase()}
|
||||||
|
valueStyle={{ color: '#fa8c16' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Cost from Time Logs"
|
||||||
|
value={budgetStatistics.totalActualCost - budgetStatistics.totalFixedCost}
|
||||||
|
precision={2}
|
||||||
|
prefix={projectCurrency.toUpperCase()}
|
||||||
|
valueStyle={{ color: '#13c2c2' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
|
<Statistic
|
||||||
|
title="Budget Status"
|
||||||
|
value={budgetStatistics.totalBudget - budgetStatistics.totalActualCost}
|
||||||
|
precision={2}
|
||||||
|
prefix={budgetStatistics.totalBudget - budgetStatistics.totalActualCost >= 0 ? '+' : ''}
|
||||||
|
suffix={`${projectCurrency.toUpperCase()} remaining`}
|
||||||
|
valueStyle={{
|
||||||
|
color: budgetStatistics.totalBudget - budgetStatistics.totalActualCost >= 0 ? '#52c41a' : '#ff4d4f'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
|
<FinanceTableWrapper activeTablesList={taskGroups} loading={loading} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const transformTasksToGanttData = (taskGroups: ITaskListGroup[]) => {
|
|||||||
parent: 0,
|
parent: 0,
|
||||||
progress: Math.round((group.done_progress || 0) * 100) / 100,
|
progress: Math.round((group.done_progress || 0) * 100) / 100,
|
||||||
details: `Status: ${group.name}`,
|
details: `Status: ${group.name}`,
|
||||||
|
$custom_class: 'gantt-group-row',
|
||||||
|
$group_type: group.name.toLowerCase().replace(/\s+/g, '-'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add individual tasks
|
// Add individual tasks
|
||||||
@@ -141,82 +143,82 @@ const ProjectViewGantt = () => {
|
|||||||
return { tasks: [], links: [] };
|
return { tasks: [], links: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test with hardcoded data first to isolate the issue
|
// // Test with hardcoded data first to isolate the issue
|
||||||
const testData = {
|
// const testData = {
|
||||||
tasks: [
|
// tasks: [
|
||||||
{
|
// {
|
||||||
id: 1,
|
// id: 1,
|
||||||
text: "Test Project",
|
// text: "Test Project",
|
||||||
start: new Date(2024, 0, 1),
|
// start: new Date(2024, 0, 1),
|
||||||
end: new Date(2024, 0, 15),
|
// end: new Date(2024, 0, 15),
|
||||||
type: "summary",
|
// type: "summary",
|
||||||
open: true,
|
// open: true,
|
||||||
parent: 0,
|
// parent: 0,
|
||||||
progress: 0.5,
|
// progress: 0.5,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 2,
|
// id: 2,
|
||||||
text: "Test Task 1",
|
// text: "Test Task 1",
|
||||||
start: new Date(2024, 0, 2),
|
// start: new Date(2024, 0, 2),
|
||||||
end: new Date(2024, 0, 8),
|
// end: new Date(2024, 0, 8),
|
||||||
type: "task",
|
// type: "task",
|
||||||
parent: 1,
|
// parent: 1,
|
||||||
progress: 0.3,
|
// progress: 0.3,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
id: 3,
|
// id: 3,
|
||||||
text: "Test Task 2",
|
// text: "Test Task 2",
|
||||||
start: new Date(2024, 0, 9),
|
// start: new Date(2024, 0, 9),
|
||||||
end: new Date(2024, 0, 14),
|
// end: new Date(2024, 0, 14),
|
||||||
type: "task",
|
// type: "task",
|
||||||
parent: 1,
|
// parent: 1,
|
||||||
progress: 0.7,
|
// progress: 0.7,
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
links: [],
|
// links: [],
|
||||||
};
|
// };
|
||||||
|
|
||||||
console.log('Using test data for debugging:', testData);
|
// console.log('Using test data for debugging:', testData);
|
||||||
return testData;
|
// return testData;
|
||||||
|
|
||||||
// Original transformation (commented out for testing)
|
// Original transformation (commented out for testing)
|
||||||
// const result = transformTasksToGanttData(taskGroups);
|
const result = transformTasksToGanttData(taskGroups);
|
||||||
// console.log('Gantt data - tasks count:', result.tasks.length);
|
console.log('Gantt data - tasks count:', result.tasks.length);
|
||||||
// if (result.tasks.length > 0) {
|
if (result.tasks.length > 0) {
|
||||||
// console.log('First task:', result.tasks[0]);
|
console.log('First task:', result.tasks[0]);
|
||||||
// console.log('Sample dates:', result.tasks[0]?.start, result.tasks[0]?.end);
|
console.log('Sample dates:', result.tasks[0]?.start, result.tasks[0]?.end);
|
||||||
// }
|
}
|
||||||
// return result;
|
return result;
|
||||||
}, [taskGroups]);
|
}, [taskGroups]);
|
||||||
|
|
||||||
// Calculate date range for the Gantt chart
|
// Calculate date range for the Gantt chart
|
||||||
const dateRange = useMemo(() => {
|
const dateRange = useMemo(() => {
|
||||||
// Fixed range for testing
|
// Fixed range for testing
|
||||||
return {
|
// return {
|
||||||
start: new Date(2023, 11, 1), // December 1, 2023
|
// start: new Date(2023, 11, 1), // December 1, 2023
|
||||||
end: new Date(2024, 1, 29), // February 29, 2024
|
// end: new Date(2024, 1, 29), // February 29, 2024
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Original dynamic calculation (commented out for testing)
|
// Original dynamic calculation (commented out for testing)
|
||||||
// if (ganttData.tasks.length === 0) {
|
if (ganttData.tasks.length === 0) {
|
||||||
// const now = new Date();
|
const now = new Date();
|
||||||
// return {
|
return {
|
||||||
// start: new Date(now.getFullYear(), now.getMonth() - 1, 1),
|
start: new Date(now.getFullYear(), now.getMonth() - 1, 1),
|
||||||
// end: new Date(now.getFullYear(), now.getMonth() + 2, 0),
|
end: new Date(now.getFullYear(), now.getMonth() + 2, 0),
|
||||||
// };
|
};
|
||||||
// }
|
}
|
||||||
|
|
||||||
// const dates = ganttData.tasks.map(task => [task.start, task.end]).flat();
|
const dates = ganttData.tasks.map(task => [task.start, task.end]).flat();
|
||||||
// const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
const minDate = new Date(Math.min(...dates.map(d => d.getTime())));
|
||||||
// const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
const maxDate = new Date(Math.max(...dates.map(d => d.getTime())));
|
||||||
|
|
||||||
// // Add some padding
|
// Add some padding
|
||||||
// const startDate = new Date(minDate);
|
const startDate = new Date(minDate);
|
||||||
// startDate.setDate(startDate.getDate() - 7);
|
startDate.setDate(startDate.getDate() - 7);
|
||||||
// const endDate = new Date(maxDate);
|
const endDate = new Date(maxDate);
|
||||||
// endDate.setDate(endDate.getDate() + 7);
|
endDate.setDate(endDate.getDate() + 7);
|
||||||
|
|
||||||
// return { start: startDate, end: endDate };
|
return { start: startDate, end: endDate };
|
||||||
}, [ganttData.tasks]);
|
}, [ganttData.tasks]);
|
||||||
|
|
||||||
// Batch initial data fetching
|
// Batch initial data fetching
|
||||||
@@ -313,11 +315,76 @@ const ProjectViewGantt = () => {
|
|||||||
background-color: #00ba94 !important;
|
background-color: #00ba94 !important;
|
||||||
border: 1px solid #099f81 !important;
|
border: 1px solid #099f81 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Highlight group names (summary tasks) */
|
||||||
|
.wx-gantt-summary {
|
||||||
|
background-color: #722ed1 !important;
|
||||||
|
border: 2px solid #531dab !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group name text styling */
|
||||||
|
.wx-gantt-row[data-task-type="summary"] .wx-gantt-cell-text {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
color: #722ed1 !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
letter-spacing: 0.5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group row background highlighting */
|
||||||
|
.wx-gantt-row[data-task-type="summary"] {
|
||||||
|
background-color: ${isDarkMode ? 'rgba(114, 46, 209, 0.1)' : 'rgba(114, 46, 209, 0.05)'} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Different colors for different group types */
|
||||||
|
.gantt-group-row .wx-gantt-cell-text,
|
||||||
|
.wx-gantt-row[data-task-id*="group-"] .wx-gantt-cell-text {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Todo/To Do groups - Red */
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Todo")) .wx-gantt-cell-text,
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("TO DO")) .wx-gantt-cell-text,
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("To Do")) .wx-gantt-cell-text {
|
||||||
|
color: #f5222d !important;
|
||||||
|
background: linear-gradient(90deg, rgba(245, 34, 45, 0.1) 0%, transparent 100%) !important;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
border-left: 4px solid #f5222d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Doing/In Progress groups - Orange */
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Doing")) .wx-gantt-cell-text,
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("IN PROGRESS")) .wx-gantt-cell-text,
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("In Progress")) .wx-gantt-cell-text {
|
||||||
|
color: #fa8c16 !important;
|
||||||
|
background: linear-gradient(90deg, rgba(250, 140, 22, 0.1) 0%, transparent 100%) !important;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
border-left: 4px solid #fa8c16 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done/Completed groups - Green */
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Done")) .wx-gantt-cell-text,
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("COMPLETED")) .wx-gantt-cell-text,
|
||||||
|
.wx-gantt-row[data-task-id*="group-"]:has(.wx-gantt-cell-text:contains("Completed")) .wx-gantt-cell-text {
|
||||||
|
color: #52c41a !important;
|
||||||
|
background: linear-gradient(90deg, rgba(82, 196, 26, 0.1) 0%, transparent 100%) !important;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
border-left: 4px solid #52c41a !important;
|
||||||
|
}
|
||||||
|
|
||||||
${isDarkMode ? `
|
${isDarkMode ? `
|
||||||
.wx-gantt-task {
|
.wx-gantt-task {
|
||||||
background-color: #37a9ef !important;
|
background-color: #37a9ef !important;
|
||||||
border: 1px solid #098cdc !important;
|
border: 1px solid #098cdc !important;
|
||||||
}
|
}
|
||||||
|
.wx-gantt-summary {
|
||||||
|
background-color: #9254de !important;
|
||||||
|
border: 2px solid #722ed1 !important;
|
||||||
|
}
|
||||||
|
.wx-gantt-row[data-task-type="summary"] .wx-gantt-cell-text {
|
||||||
|
color: #b37feb !important;
|
||||||
|
}
|
||||||
` : ''}
|
` : ''}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
@@ -338,13 +405,16 @@ const ProjectViewGantt = () => {
|
|||||||
<Gantt
|
<Gantt
|
||||||
tasks={ganttData.tasks}
|
tasks={ganttData.tasks}
|
||||||
links={ganttData.links}
|
links={ganttData.links}
|
||||||
start={new Date(2024, 0, 1)}
|
start={dateRange.start}
|
||||||
end={new Date(2024, 0, 31)}
|
end={dateRange.end}
|
||||||
scales={[
|
scales={[
|
||||||
|
{ unit: 'month', step: 1, format: 'MMMM yyyy' },
|
||||||
{ unit: 'day', step: 1, format: 'd' }
|
{ unit: 'day', step: 1, format: 'd' }
|
||||||
]}
|
]}
|
||||||
columns={[
|
columns={[
|
||||||
{ id: 'text', header: 'Task Name', width: 200 }
|
{ id: 'text', header: 'Task Name', width: 200 },
|
||||||
|
{ id: 'start', header: 'Start Date', width: 100 },
|
||||||
|
{ id: 'end', header: 'End Date', width: 100 }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</WillowDark>
|
</WillowDark>
|
||||||
@@ -353,13 +423,16 @@ const ProjectViewGantt = () => {
|
|||||||
<Gantt
|
<Gantt
|
||||||
tasks={ganttData.tasks}
|
tasks={ganttData.tasks}
|
||||||
links={ganttData.links}
|
links={ganttData.links}
|
||||||
start={new Date(2024, 0, 1)}
|
start={dateRange.start}
|
||||||
end={new Date(2024, 0, 31)}
|
end={dateRange.end}
|
||||||
scales={[
|
scales={[
|
||||||
|
{ unit: 'month', step: 1, format: 'MMMM yyyy' },
|
||||||
{ unit: 'day', step: 1, format: 'd' }
|
{ unit: 'day', step: 1, format: 'd' }
|
||||||
]}
|
]}
|
||||||
columns={[
|
columns={[
|
||||||
{ id: 'text', header: 'Task Name', width: 200 }
|
{ id: 'text', header: 'Task Name', width: 200 },
|
||||||
|
{ id: 'start', header: 'Start Date', width: 100 },
|
||||||
|
{ id: 'end', header: 'End Date', width: 100 }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Willow>
|
</Willow>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Empty,
|
||||||
Flex,
|
Flex,
|
||||||
Input,
|
Input,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@@ -14,7 +15,10 @@ import {
|
|||||||
TableProps,
|
TableProps,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import type { TablePaginationConfig } from 'antd/es/table';
|
||||||
|
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
import { colors } from '../../../styles/colors';
|
import { colors } from '../../../styles/colors';
|
||||||
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
@@ -41,8 +45,13 @@ interface PaginationType {
|
|||||||
const RatecardSettings: React.FC = () => {
|
const RatecardSettings: React.FC = () => {
|
||||||
const { t } = useTranslation('/settings/ratecard-settings');
|
const { t } = useTranslation('/settings/ratecard-settings');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
useDocumentTitle('Manage Rate Cards');
|
useDocumentTitle('Manage Rate Cards');
|
||||||
|
|
||||||
|
// Redux state
|
||||||
const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen);
|
const isDrawerOpen = useAppSelector(state => state.financeReducer.isRatecardDrawerOpen);
|
||||||
|
|
||||||
|
// Local state
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -58,12 +67,14 @@ const RatecardSettings: React.FC = () => {
|
|||||||
size: 'small',
|
size: 'small',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Memoized filtered data
|
||||||
const filteredRatecardsData = useMemo(() => {
|
const filteredRatecardsData = useMemo(() => {
|
||||||
return ratecardsList.filter((item) =>
|
return ratecardsList.filter((item) =>
|
||||||
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
item.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
}, [ratecardsList, searchQuery]);
|
}, [ratecardsList, searchQuery]);
|
||||||
|
|
||||||
|
// Fetch rate cards with error handling
|
||||||
const fetchRateCards = useCallback(async () => {
|
const fetchRateCards = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -77,36 +88,46 @@ const RatecardSettings: React.FC = () => {
|
|||||||
if (response.done) {
|
if (response.done) {
|
||||||
setRatecardsList(response.body.data || []);
|
setRatecardsList(response.body.data || []);
|
||||||
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
|
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
|
||||||
|
} else {
|
||||||
|
messageApi.error(t('fetchError') || 'Failed to fetch rate cards');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch rate cards:', error);
|
console.error('Failed to fetch rate cards:', error);
|
||||||
|
messageApi.error(t('fetchError') || 'Failed to fetch rate cards');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
|
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery, t, messageApi]);
|
||||||
|
|
||||||
|
// Fetch rate cards when drawer state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRateCards();
|
fetchRateCards();
|
||||||
}, [toggleRatecardDrawer, isDrawerOpen]);
|
}, [fetchRateCards, isDrawerOpen]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Handle rate card creation
|
||||||
const handleRatecardCreate = useCallback(async () => {
|
const handleRatecardCreate = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resultAction = await dispatch(createRateCard({
|
||||||
|
name: 'Untitled Rate Card',
|
||||||
|
jobRolesList: [],
|
||||||
|
currency: 'LKR',
|
||||||
|
}) as any);
|
||||||
|
|
||||||
const resultAction = await dispatch(createRateCard({
|
if (createRateCard.fulfilled.match(resultAction)) {
|
||||||
name: 'Untitled Rate Card',
|
const created = resultAction.payload;
|
||||||
jobRolesList: [],
|
setRatecardDrawerType('update');
|
||||||
currency: 'LKR',
|
setSelectedRatecardId(created.id ?? null);
|
||||||
}) as any);
|
dispatch(toggleRatecardDrawer());
|
||||||
|
} else {
|
||||||
if (createRateCard.fulfilled.match(resultAction)) {
|
messageApi.error(t('createError') || 'Failed to create rate card');
|
||||||
const created = resultAction.payload;
|
}
|
||||||
setRatecardDrawerType('update');
|
} catch (error) {
|
||||||
setSelectedRatecardId(created.id ?? null);
|
console.error('Failed to create rate card:', error);
|
||||||
dispatch(toggleRatecardDrawer());
|
messageApi.error(t('createError') || 'Failed to create rate card');
|
||||||
}
|
}
|
||||||
}, [dispatch]);
|
}, [dispatch, t, messageApi]);
|
||||||
|
|
||||||
|
// Handle rate card update
|
||||||
const handleRatecardUpdate = useCallback((id: string) => {
|
const handleRatecardUpdate = useCallback((id: string) => {
|
||||||
setRatecardDrawerType('update');
|
setRatecardDrawerType('update');
|
||||||
dispatch(fetchRateCardById(id));
|
dispatch(fetchRateCardById(id));
|
||||||
@@ -114,26 +135,32 @@ const RatecardSettings: React.FC = () => {
|
|||||||
dispatch(toggleRatecardDrawer());
|
dispatch(toggleRatecardDrawer());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Handle table changes
|
||||||
|
const handleTableChange = useCallback((
|
||||||
|
newPagination: TablePaginationConfig,
|
||||||
const handleTableChange = useCallback((newPagination: any, filters: any, sorter: any) => {
|
filters: Record<string, FilterValue | null>,
|
||||||
|
sorter: SorterResult<RatecardType> | SorterResult<RatecardType>[]
|
||||||
|
) => {
|
||||||
|
const sorterResult = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||||
setPagination(prev => ({
|
setPagination(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current: newPagination.current,
|
current: newPagination.current || 1,
|
||||||
pageSize: newPagination.pageSize,
|
pageSize: newPagination.pageSize || DEFAULT_PAGE_SIZE,
|
||||||
field: sorter.field || 'name',
|
field: (sorterResult?.field as string) || 'name',
|
||||||
order: sorter.order === 'ascend' ? 'asc' : 'desc',
|
order: sorterResult?.order === 'ascend' ? 'asc' : 'desc',
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Table columns configuration
|
||||||
const columns: TableProps['columns'] = useMemo(() => [
|
const columns: TableProps['columns'] = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: 'rateName',
|
key: 'rateName',
|
||||||
title: t('nameColumn'),
|
title: t('nameColumn'),
|
||||||
render: (record: RatecardType) => (
|
render: (record: RatecardType) => (
|
||||||
<Typography.Text style={{ color: '#1890ff', cursor: 'pointer' }}
|
<Typography.Text
|
||||||
onClick={() => setSelectedRatecardId(record.id ?? null)}>
|
style={{ color: '#1890ff', cursor: 'pointer' }}
|
||||||
|
onClick={() => record.id && handleRatecardUpdate(record.id)}
|
||||||
|
>
|
||||||
{record.name}
|
{record.name}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
),
|
),
|
||||||
@@ -142,7 +169,7 @@ const RatecardSettings: React.FC = () => {
|
|||||||
key: 'created',
|
key: 'created',
|
||||||
title: t('createdColumn'),
|
title: t('createdColumn'),
|
||||||
render: (record: RatecardType) => (
|
render: (record: RatecardType) => (
|
||||||
<Typography.Text onClick={() => setSelectedRatecardId(record.id ?? null)}>
|
<Typography.Text onClick={() => record.id && handleRatecardUpdate(record.id)}>
|
||||||
{durationDateFormat(record.created_at)}
|
{durationDateFormat(record.created_at)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
),
|
),
|
||||||
@@ -152,7 +179,7 @@ const RatecardSettings: React.FC = () => {
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (record: RatecardType) => (
|
render: (record: RatecardType) => (
|
||||||
<Flex gap={8} className="hidden group-hover:flex">
|
<Flex gap={8} className="hidden group-hover:flex">
|
||||||
<Tooltip title="Edit">
|
<Tooltip title={t('editTooltip') || 'Edit'}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
@@ -166,14 +193,19 @@ const RatecardSettings: React.FC = () => {
|
|||||||
cancelText={t('deleteConfirmationCancel')}
|
cancelText={t('deleteConfirmationCancel')}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
if (record.id) {
|
try {
|
||||||
await dispatch(deleteRateCard(record.id));
|
if (record.id) {
|
||||||
await fetchRateCards();
|
await dispatch(deleteRateCard(record.id));
|
||||||
|
await fetchRateCards();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete rate card:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip title="Delete">
|
<Tooltip title={t('deleteTooltip') || 'Delete'}>
|
||||||
<Button
|
<Button
|
||||||
shape="default"
|
shape="default"
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
@@ -184,46 +216,52 @@ const RatecardSettings: React.FC = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
], [t, handleRatecardUpdate]);
|
], [t, handleRatecardUpdate, fetchRateCards, dispatch, messageApi]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<>
|
||||||
style={{ width: '100%' }}
|
{contextHolder}
|
||||||
title={
|
<Card
|
||||||
<Flex justify="flex-end" align="center" gap={8}>
|
style={{ width: '100%' }}
|
||||||
<Input
|
title={
|
||||||
value={searchQuery}
|
<Flex justify="flex-end" align="center" gap={8}>
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<Input
|
||||||
placeholder={t('searchPlaceholder')}
|
value={searchQuery}
|
||||||
style={{ maxWidth: 232 }}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
suffix={<SearchOutlined />}
|
placeholder={t('searchPlaceholder')}
|
||||||
/>
|
style={{ maxWidth: 232 }}
|
||||||
<Button type="primary" onClick={handleRatecardCreate}>
|
suffix={<SearchOutlined />}
|
||||||
{t('createRatecard')}
|
/>
|
||||||
</Button>
|
<Button type="primary" onClick={handleRatecardCreate}>
|
||||||
</Flex>
|
{t('createRatecard')}
|
||||||
}
|
</Button>
|
||||||
>
|
</Flex>
|
||||||
<Table
|
}
|
||||||
loading={loading}
|
>
|
||||||
className="custom-two-colors-row-table"
|
<Table
|
||||||
dataSource={filteredRatecardsData}
|
loading={loading}
|
||||||
columns={columns}
|
className="custom-two-colors-row-table"
|
||||||
rowKey="id"
|
dataSource={filteredRatecardsData}
|
||||||
pagination={{
|
columns={columns}
|
||||||
...pagination,
|
rowKey="id"
|
||||||
showSizeChanger: true,
|
pagination={{
|
||||||
onChange: (page, pageSize) => setPagination(prev => ({ ...prev, current: page, pageSize })),
|
...pagination,
|
||||||
}}
|
showSizeChanger: true,
|
||||||
onChange={handleTableChange}
|
onChange: (page, pageSize) => setPagination(prev => ({ ...prev, current: page, pageSize })),
|
||||||
rowClassName="group"
|
}}
|
||||||
/>
|
onChange={handleTableChange}
|
||||||
<RatecardDrawer
|
rowClassName="group"
|
||||||
type={ratecardDrawerType}
|
locale={{
|
||||||
ratecardId={selectedRatecardId || ''}
|
emptyText: <Empty description={t('noRatecardsFound')} />,
|
||||||
onSaved={fetchRateCards} // Pass the fetch function as a prop
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
<RatecardDrawer
|
||||||
|
type={ratecardDrawerType}
|
||||||
|
ratecardId={selectedRatecardId || ''}
|
||||||
|
onSaved={fetchRateCards}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user