feat: Implement Ratecard Drawer and Finance Table
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"financeText": "Finance",
|
||||||
|
"ratecardSingularText": "Rate Card",
|
||||||
|
"groupByText": "Group by",
|
||||||
|
"statusText": "Status",
|
||||||
|
"phaseText": "Phase",
|
||||||
|
"priorityText": "Priority",
|
||||||
|
"exportButton": "Export",
|
||||||
|
"currencyText": "Currency",
|
||||||
|
"importButton": "Import",
|
||||||
|
|
||||||
|
"taskColumn": "Task",
|
||||||
|
"membersColumn": "Members",
|
||||||
|
"hoursColumn": "Hours",
|
||||||
|
"costColumn": "Cost",
|
||||||
|
"fixedCostColumn": "Fixed Cost",
|
||||||
|
"totalBudgetedCostColumn": "Total Budgeted Cost",
|
||||||
|
"totalActualCostColumn": "Total Actual Cost",
|
||||||
|
"varianceColumn": "Variance",
|
||||||
|
"totalText": "Total",
|
||||||
|
|
||||||
|
"addRoleButton": "+ Add Role",
|
||||||
|
"ratecardImportantNotice": "* This rate card is generated based on the company's standard job titles and rates. However, you have the flexibility to modify it according to the project. These changes will not impact the organization's standard job titles and rates.",
|
||||||
|
"saveButton": "Save",
|
||||||
|
|
||||||
|
"jobTitleColumn": "Job Title",
|
||||||
|
"ratePerHourColumn": "Rate per hour",
|
||||||
|
"ratecardPluralText": "Rate Cards",
|
||||||
|
"labourHoursColumn": "Labour Hours"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"financeText": "Finanzas",
|
||||||
|
"ratecardSingularText": "Tarifa",
|
||||||
|
"groupByText": "Agrupar por",
|
||||||
|
"statusText": "Estado",
|
||||||
|
"phaseText": "Fase",
|
||||||
|
"priorityText": "Prioridad",
|
||||||
|
"exportButton": "Exportar",
|
||||||
|
"currencyText": "Moneda",
|
||||||
|
"importButton": "Importar",
|
||||||
|
|
||||||
|
"taskColumn": "Tarea",
|
||||||
|
"membersColumn": "Miembros",
|
||||||
|
"hoursColumn": "Horas",
|
||||||
|
"costColumn": "Costo",
|
||||||
|
"fixedCostColumn": "Costo Fijo",
|
||||||
|
"totalBudgetedCostColumn": "Costo Total Presupuestado",
|
||||||
|
"totalActualCostColumn": "Costo Total Real",
|
||||||
|
"varianceColumn": "Diferencia",
|
||||||
|
"totalText": "Total",
|
||||||
|
|
||||||
|
"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.",
|
||||||
|
"saveButton": "Guardar",
|
||||||
|
|
||||||
|
"jobTitleColumn": "Título del Trabajo",
|
||||||
|
"ratePerHourColumn": "Tarifa por hora",
|
||||||
|
"ratecardPluralText": "Tarifas",
|
||||||
|
"labourHoursColumn": "Horas de Trabajo"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"financeText": "Finanças",
|
||||||
|
"ratecardSingularText": "Tabela de Taxas",
|
||||||
|
"groupByText": "Agrupar por",
|
||||||
|
"statusText": "Status",
|
||||||
|
"phaseText": "Fase",
|
||||||
|
"priorityText": "Prioridade",
|
||||||
|
"exportButton": "Exportar",
|
||||||
|
"currencyText": "Moeda",
|
||||||
|
"importButton": "Importar",
|
||||||
|
|
||||||
|
"taskColumn": "Tarefa",
|
||||||
|
"membersColumn": "Membros",
|
||||||
|
"hoursColumn": "Horas",
|
||||||
|
"costColumn": "Custo",
|
||||||
|
"fixedCostColumn": "Custo Fixo",
|
||||||
|
"totalBudgetedCostColumn": "Custo Total Orçado",
|
||||||
|
"totalActualCostColumn": "Custo Total Real",
|
||||||
|
"varianceColumn": "Variação",
|
||||||
|
"totalText": "Total",
|
||||||
|
|
||||||
|
"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.",
|
||||||
|
"saveButton": "Salvar",
|
||||||
|
|
||||||
|
"jobTitleColumn": "Título do Cargo",
|
||||||
|
"ratePerHourColumn": "Taxa por Hora",
|
||||||
|
"ratecardPluralText": "Tabelas de Taxas",
|
||||||
|
"labourHoursColumn": "Horas de Trabalho"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ import projectReportsTableColumnsReducer from '../features/reporting/projectRepo
|
|||||||
import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice';
|
import projectReportsReducer from '../features/reporting/projectReports/project-reports-slice';
|
||||||
import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice';
|
import membersReportsReducer from '../features/reporting/membersReports/membersReportsSlice';
|
||||||
import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice';
|
import timeReportsOverviewReducer from '@features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import financeReducer from '../features/finance/finance-slice';
|
||||||
import roadmapReducer from '../features/roadmap/roadmap-slice';
|
import roadmapReducer from '../features/roadmap/roadmap-slice';
|
||||||
import teamMembersReducer from '@features/team-members/team-members.slice';
|
import teamMembersReducer from '@features/team-members/team-members.slice';
|
||||||
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||||
@@ -155,6 +155,7 @@ export const store = configureStore({
|
|||||||
roadmapReducer: roadmapReducer,
|
roadmapReducer: roadmapReducer,
|
||||||
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
|
groupByFilterDropdownReducer: groupByFilterDropdownReducer,
|
||||||
timeReportsOverviewReducer: timeReportsOverviewReducer,
|
timeReportsOverviewReducer: timeReportsOverviewReducer,
|
||||||
|
financeReducer: financeReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Drawer, Typography } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
|
import { themeWiseColor } from '../../../utils/themeWiseColor';
|
||||||
|
import { toggleFinanceDrawer } from '../finance-slice';
|
||||||
|
|
||||||
|
const FinanceDrawer = ({ task }: { task: any }) => {
|
||||||
|
const [selectedTask, setSelectedTask] = useState(task);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
}, [task]);
|
||||||
|
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
|
// get theme data from theme reducer
|
||||||
|
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||||
|
|
||||||
|
const isDrawerOpen = useAppSelector(
|
||||||
|
(state) => state.financeReducer.isFinanceDrawerOpen
|
||||||
|
);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const currency = useAppSelector(
|
||||||
|
(state) => state.financeReducer.currency
|
||||||
|
).toUpperCase();
|
||||||
|
|
||||||
|
// function handle drawer close
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedTask(null);
|
||||||
|
dispatch(toggleFinanceDrawer());
|
||||||
|
};
|
||||||
|
|
||||||
|
// group members by job roles and calculate labor hours and costs
|
||||||
|
const groupedMembers =
|
||||||
|
selectedTask?.members?.reduce((acc: any, member: any) => {
|
||||||
|
const memberHours = selectedTask.hours / selectedTask.members.length;
|
||||||
|
const memberCost = memberHours * member.hourlyRate;
|
||||||
|
|
||||||
|
if (!acc[member.jobRole]) {
|
||||||
|
acc[member.jobRole] = {
|
||||||
|
jobRole: member.jobRole,
|
||||||
|
laborHours: 0,
|
||||||
|
cost: 0,
|
||||||
|
members: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[member.jobRole].laborHours += memberHours;
|
||||||
|
acc[member.jobRole].cost += memberCost;
|
||||||
|
acc[member.jobRole].members.push({
|
||||||
|
name: member.name,
|
||||||
|
laborHours: memberHours,
|
||||||
|
cost: memberCost,
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {}) || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
|
{selectedTask?.task || t('noTaskSelected')}
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
open={isDrawerOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
destroyOnClose={true}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
'#F5F5F5',
|
||||||
|
'#1d1d1d',
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
></th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('labourHoursColumn')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('costColumn')} ({currency})
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<div className="mb-4"></div>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{Object.values(groupedMembers).map((group: any) => (
|
||||||
|
<React.Fragment key={group.jobRole}>
|
||||||
|
{/* Group Header */}
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
'#D9D9D9',
|
||||||
|
'#000',
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
height: 56,
|
||||||
|
}}
|
||||||
|
className="border-b-[1px] font-semibold"
|
||||||
|
>
|
||||||
|
<td style={{ padding: 8 }}>{group.jobRole}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.laborHours}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.cost}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* Member Rows */}
|
||||||
|
{group.members.map((member: any, index: number) => (
|
||||||
|
<tr
|
||||||
|
key={`${group.jobRole}-${index}`}
|
||||||
|
className="border-b-[1px]"
|
||||||
|
style={{ height: 56 }}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
paddingLeft: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.laborHours}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.cost}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinanceDrawer;
|
||||||
42
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
42
worklenz-frontend/src/features/finance/finance-slice.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
type financeState = {
|
||||||
|
isRatecardDrawerOpen: boolean;
|
||||||
|
isFinanceDrawerOpen: boolean;
|
||||||
|
isImportRatecardsDrawerOpen: boolean;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: financeState = {
|
||||||
|
isRatecardDrawerOpen: false,
|
||||||
|
isFinanceDrawerOpen: false,
|
||||||
|
isImportRatecardsDrawerOpen: false,
|
||||||
|
currency: 'LKR',
|
||||||
|
};
|
||||||
|
|
||||||
|
const financeSlice = createSlice({
|
||||||
|
name: 'financeReducer',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
toggleRatecardDrawer: (state) => {
|
||||||
|
state.isRatecardDrawerOpen = !state.isRatecardDrawerOpen;
|
||||||
|
},
|
||||||
|
toggleFinanceDrawer: (state) => {
|
||||||
|
state.isFinanceDrawerOpen = !state.isFinanceDrawerOpen;
|
||||||
|
},
|
||||||
|
toggleImportRatecardsDrawer: (state) => {
|
||||||
|
state.isImportRatecardsDrawerOpen = !state.isImportRatecardsDrawerOpen;
|
||||||
|
},
|
||||||
|
changeCurrency: (state, action: PayloadAction<string>) => {
|
||||||
|
state.currency = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
toggleRatecardDrawer,
|
||||||
|
toggleFinanceDrawer,
|
||||||
|
toggleImportRatecardsDrawer,
|
||||||
|
changeCurrency,
|
||||||
|
} = financeSlice.actions;
|
||||||
|
export default financeSlice.reducer;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Drawer, Typography, Button, Table, Menu, Flex } from 'antd';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
|
import { fetchData } from '../../../utils/fetchData';
|
||||||
|
import { toggleImportRatecardsDrawer } from '../finance-slice';
|
||||||
|
import { RatecardType } from '@/types/project/ratecard.types';
|
||||||
|
const ImportRatecardsDrawer: React.FC = () => {
|
||||||
|
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
||||||
|
const [selectedRatecardId, setSelectedRatecardId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
|
// get drawer state from client reducer
|
||||||
|
const isDrawerOpen = useAppSelector(
|
||||||
|
(state) => state.financeReducer.isImportRatecardsDrawerOpen
|
||||||
|
);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// fetch rate cards data
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// get currently using currency from finance reducer
|
||||||
|
const currency = useAppSelector(
|
||||||
|
(state) => state.financeReducer.currency
|
||||||
|
).toUpperCase();
|
||||||
|
|
||||||
|
// find the selected rate card's job roles
|
||||||
|
const selectedRatecard =
|
||||||
|
ratecardsList.find(
|
||||||
|
(ratecard) => ratecard.ratecardId === selectedRatecardId
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
// table columns
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('jobTitleColumn'),
|
||||||
|
dataIndex: 'jobTitle',
|
||||||
|
render: (text: string) => (
|
||||||
|
<Typography.Text className="group-hover:text-[#1890ff]">
|
||||||
|
{text}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
|
dataIndex: 'ratePerHour',
|
||||||
|
render: (text: number) => <Typography.Text>{text}</Typography.Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
|
{t('ratecardsPluralText')}
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<Button type="primary">Import</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={isDrawerOpen}
|
||||||
|
onClose={() => dispatch(toggleImportRatecardsDrawer())}
|
||||||
|
width={1000}
|
||||||
|
>
|
||||||
|
<Flex gap={12}>
|
||||||
|
{/* sidebar menu */}
|
||||||
|
<Menu
|
||||||
|
mode="vertical"
|
||||||
|
style={{ width: '20%' }}
|
||||||
|
selectedKeys={
|
||||||
|
selectedRatecardId
|
||||||
|
? [selectedRatecardId]
|
||||||
|
: [ratecardsList[0]?.ratecardId]
|
||||||
|
}
|
||||||
|
onClick={({ key }) => setSelectedRatecardId(key)}
|
||||||
|
>
|
||||||
|
{ratecardsList.map((ratecard) => (
|
||||||
|
<Menu.Item key={ratecard.ratecardId}>
|
||||||
|
{ratecard.ratecardName}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* table for job roles */}
|
||||||
|
<Table
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
dataSource={selectedRatecard?.jobRolesList || []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey={(record) => record.jobId}
|
||||||
|
onRow={() => {
|
||||||
|
return {
|
||||||
|
className: 'group',
|
||||||
|
style: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportRatecardsDrawer;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { Drawer, Select, Typography, Flex, Button, Input, Table } from 'antd';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '../../../hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '../../../hooks/useAppDispatch';
|
||||||
|
import { fetchData } from '../../../utils/fetchData';
|
||||||
|
import { toggleRatecardDrawer } from '../finance-slice';
|
||||||
|
import { RatecardType } from '@/types/project/ratecard.types';
|
||||||
|
import { JobType } from '@/types/project/job.types';
|
||||||
|
|
||||||
|
const RatecardDrawer = ({
|
||||||
|
type,
|
||||||
|
ratecardId,
|
||||||
|
}: {
|
||||||
|
type: 'create' | 'update';
|
||||||
|
ratecardId: string;
|
||||||
|
}) => {
|
||||||
|
const [ratecardsList, setRatecardsList] = useState<RatecardType[]>([]);
|
||||||
|
// initial Job Roles List (dummy data)
|
||||||
|
const [roles, setRoles] = useState<JobType[]>([]);
|
||||||
|
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('ratecard-settings');
|
||||||
|
|
||||||
|
// get drawer state from client reducer
|
||||||
|
const isDrawerOpen = useAppSelector(
|
||||||
|
(state) => state.financeReducer.isRatecardDrawerOpen
|
||||||
|
);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// fetch rate cards data
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData('/finance-mock-data/ratecards-data.json', setRatecardsList);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// get currently selected ratecard
|
||||||
|
const selectedRatecard = ratecardsList.find(
|
||||||
|
(ratecard) => ratecard.ratecardId === ratecardId
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
type === 'update'
|
||||||
|
? setRoles(selectedRatecard?.jobRolesList || [])
|
||||||
|
: setRoles([
|
||||||
|
{
|
||||||
|
jobId: 'J001',
|
||||||
|
jobTitle: 'Project Manager',
|
||||||
|
ratePerHour: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: 'J002',
|
||||||
|
jobTitle: 'Senior Software Engineer',
|
||||||
|
ratePerHour: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: 'J003',
|
||||||
|
jobTitle: 'Junior Software Engineer',
|
||||||
|
ratePerHour: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: 'J004',
|
||||||
|
jobTitle: 'UI/UX Designer',
|
||||||
|
ratePerHour: 30,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, [selectedRatecard?.jobRolesList, type]);
|
||||||
|
|
||||||
|
// get currently using currency from finance reducer
|
||||||
|
const currency = useAppSelector(
|
||||||
|
(state) => state.financeReducer.currency
|
||||||
|
).toUpperCase();
|
||||||
|
|
||||||
|
// add new job role handler
|
||||||
|
const handleAddRole = () => {
|
||||||
|
const newRole = {
|
||||||
|
jobId: `J00${roles.length + 1}`,
|
||||||
|
jobTitle: 'New Role',
|
||||||
|
ratePerHour: 0,
|
||||||
|
};
|
||||||
|
setRoles([...roles, newRole]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// table columns
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('jobTitleColumn'),
|
||||||
|
dataIndex: 'jobTitle',
|
||||||
|
render: (text: string, record: any, index: number) => (
|
||||||
|
<Input
|
||||||
|
value={text}
|
||||||
|
placeholder="Enter job title"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
padding: 0,
|
||||||
|
color: '#1890ff',
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedRoles = [...roles];
|
||||||
|
updatedRoles[index].jobTitle = e.target.value;
|
||||||
|
setRoles(updatedRoles);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
|
dataIndex: 'ratePerHour',
|
||||||
|
render: (text: number, record: any, index: number) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={text}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedRoles = [...roles];
|
||||||
|
updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0;
|
||||||
|
setRoles(updatedRoles);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Flex align="center" justify="space-between">
|
||||||
|
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
|
{type === 'update'
|
||||||
|
? selectedRatecard?.ratecardName
|
||||||
|
: 'Untitled Rate Card'}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Typography.Text>{t('currency')}</Typography.Text>
|
||||||
|
<Select
|
||||||
|
defaultValue={'lkr'}
|
||||||
|
options={[
|
||||||
|
{ value: 'lkr', label: 'LKR' },
|
||||||
|
{ value: 'usd', label: 'USD' },
|
||||||
|
{ value: 'inr', label: 'INR' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
open={isDrawerOpen}
|
||||||
|
onClose={() => dispatch(toggleRatecardDrawer())}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
{/* ratecard Table directly inside the Drawer */}
|
||||||
|
<Table
|
||||||
|
dataSource={roles}
|
||||||
|
columns={columns}
|
||||||
|
rowKey={(record) => record.jobId}
|
||||||
|
pagination={false}
|
||||||
|
footer={() => (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={handleAddRole}
|
||||||
|
style={{ width: 'fit-content' }}
|
||||||
|
>
|
||||||
|
{t('addRoleButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Flex justify="end" gap={16} style={{ marginTop: 16 }}>
|
||||||
|
<Button type="primary">{t('saveButton')}</Button>
|
||||||
|
</Flex>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RatecardDrawer;
|
||||||
@@ -5,6 +5,7 @@ import ProjectViewMembers from '@/pages/projects/projectView/members/project-vie
|
|||||||
import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates';
|
import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates';
|
||||||
import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list';
|
import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list';
|
||||||
import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board';
|
import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board';
|
||||||
|
import ProjectViewFinance from '@/pages/projects/projectView/finance/project-view-finance';
|
||||||
|
|
||||||
// type of a tab items
|
// type of a tab items
|
||||||
type TabItems = {
|
type TabItems = {
|
||||||
@@ -67,4 +68,10 @@ export const tabItems: TabItems[] = [
|
|||||||
label: 'Updates',
|
label: 'Updates',
|
||||||
element: React.createElement(ProjectViewUpdates),
|
element: React.createElement(ProjectViewUpdates),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
index: 8,
|
||||||
|
key: 'finance',
|
||||||
|
label: 'Finance',
|
||||||
|
element: React.createElement(ProjectViewFinance),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
type FinanceTableColumnsType = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
width: number;
|
||||||
|
type: 'string' | 'hours' | 'currency';
|
||||||
|
};
|
||||||
|
|
||||||
|
// finance table columns
|
||||||
|
export const financeTableColumns: FinanceTableColumnsType[] = [
|
||||||
|
{
|
||||||
|
key: 'task',
|
||||||
|
name: 'task',
|
||||||
|
width: 240,
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'members',
|
||||||
|
name: 'members',
|
||||||
|
width: 160,
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hours',
|
||||||
|
name: 'hours',
|
||||||
|
width: 80,
|
||||||
|
type: 'hours',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost',
|
||||||
|
name: 'cost',
|
||||||
|
width: 120,
|
||||||
|
type: 'currency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fixedCost',
|
||||||
|
name: 'fixedCost',
|
||||||
|
width: 120,
|
||||||
|
type: 'currency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalBudget',
|
||||||
|
name: 'totalBudgetedCost',
|
||||||
|
width: 120,
|
||||||
|
type: 'currency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalActual',
|
||||||
|
name: 'totalActualCost',
|
||||||
|
width: 120,
|
||||||
|
type: 'currency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'variance',
|
||||||
|
name: 'variance',
|
||||||
|
width: 120,
|
||||||
|
type: 'currency',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import FinanceTableWrapper from './finance-table/finance-table-wrapper';
|
||||||
|
import { fetchData } from '../../../../../utils/fetchData';
|
||||||
|
|
||||||
|
const FinanceTab = ({
|
||||||
|
groupType,
|
||||||
|
}: {
|
||||||
|
groupType: 'status' | 'priority' | 'phases';
|
||||||
|
}) => {
|
||||||
|
// Save each table's list according to the groups
|
||||||
|
const [statusTables, setStatusTables] = useState<any[]>([]);
|
||||||
|
const [priorityTables, setPriorityTables] = useState<any[]>([]);
|
||||||
|
const [activeTablesList, setActiveTablesList] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Fetch data for status tables
|
||||||
|
useMemo(() => {
|
||||||
|
fetchData('/finance-mock-data/finance-task-status.json', setStatusTables);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch data for priority tables
|
||||||
|
useMemo(() => {
|
||||||
|
fetchData(
|
||||||
|
'/finance-mock-data/finance-task-priority.json',
|
||||||
|
setPriorityTables
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update activeTablesList based on groupType and fetched data
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupType === 'status') {
|
||||||
|
setActiveTablesList(statusTables);
|
||||||
|
} else if (groupType === 'priority') {
|
||||||
|
setActiveTablesList(priorityTables);
|
||||||
|
}
|
||||||
|
}, [groupType, priorityTables, statusTables]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FinanceTableWrapper activeTablesList={activeTablesList} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinanceTab;
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Checkbox, Flex, Typography } from 'antd';
|
||||||
|
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
|
||||||
|
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
||||||
|
import { toggleFinanceDrawer } from '@/features/finance/finance-slice';
|
||||||
|
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
|
||||||
|
import FinanceTable from './finance-table';
|
||||||
|
import FinanceDrawer from '@/features/finance/finance-drawer/finance-drawer';
|
||||||
|
|
||||||
|
const FinanceTableWrapper = ({
|
||||||
|
activeTablesList,
|
||||||
|
}: {
|
||||||
|
activeTablesList: any;
|
||||||
|
}) => {
|
||||||
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
|
|
||||||
|
//? this state for inside this state individualy in finance table only display the data of the last table's task when a task is clicked The selectedTask state does not synchronize across tables so thats why move the selectedTask state to a parent component
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// function on task click
|
||||||
|
const onTaskClick = (task: any) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
dispatch(toggleFinanceDrawer());
|
||||||
|
};
|
||||||
|
|
||||||
|
// trigger the table scrolling
|
||||||
|
useEffect(() => {
|
||||||
|
const tableContainer = document.querySelector('.tasklist-container');
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (tableContainer) {
|
||||||
|
setIsScrolling(tableContainer.scrollLeft > 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// add the scroll event listener
|
||||||
|
tableContainer?.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
tableContainer?.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// get theme data from theme reducer
|
||||||
|
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||||
|
|
||||||
|
// get tasklist and currently using currency from finance reducer
|
||||||
|
const { currency } = useAppSelector((state) => state.financeReducer);
|
||||||
|
|
||||||
|
// totals of all the tasks
|
||||||
|
const totals = activeTablesList.reduce(
|
||||||
|
(
|
||||||
|
acc: {
|
||||||
|
hours: number;
|
||||||
|
cost: number;
|
||||||
|
fixedCost: number;
|
||||||
|
totalBudget: number;
|
||||||
|
totalActual: number;
|
||||||
|
variance: number;
|
||||||
|
},
|
||||||
|
table: { tasks: any[] }
|
||||||
|
) => {
|
||||||
|
table.tasks.forEach((task: any) => {
|
||||||
|
acc.hours += task.hours || 0;
|
||||||
|
acc.cost += task.cost || 0;
|
||||||
|
acc.fixedCost += task.fixedCost || 0;
|
||||||
|
acc.totalBudget += task.totalBudget || 0;
|
||||||
|
acc.totalActual += task.totalActual || 0;
|
||||||
|
acc.variance += task.variance || 0;
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hours: 0,
|
||||||
|
cost: 0,
|
||||||
|
fixedCost: 0,
|
||||||
|
totalBudget: 0,
|
||||||
|
totalActual: 0,
|
||||||
|
variance: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFinancialTableHeaderContent = (columnKey: any) => {
|
||||||
|
switch (columnKey) {
|
||||||
|
case 'hours':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
|
{totals.hours}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'cost':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
|
{totals.cost}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'fixedCost':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
|
{totals.fixedCost}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'totalBudget':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
|
{totals.totalBudget}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'totalActual':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
|
{totals.totalActual}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'variance':
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
style={{
|
||||||
|
color: totals.variance < 0 ? '#FF0000' : '#6DC376',
|
||||||
|
fontSize: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totals.variance}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// layout styles for table and the columns
|
||||||
|
const customColumnHeaderStyles = (key: string) =>
|
||||||
|
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||||
|
|
||||||
|
const customColumnStyles = (key: string) =>
|
||||||
|
`px-2 text-left ${key === 'totalRow' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[68px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Flex
|
||||||
|
vertical
|
||||||
|
className="tasklist-container min-h-0 max-w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
fontWeight: 600,
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
'#fafafa',
|
||||||
|
'#1d1d1d',
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
borderBlockEnd: `2px solid rgb(0 0 0 / 0.05)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{ width: 32, paddingInline: 16 }}
|
||||||
|
className={customColumnHeaderStyles('selector')}
|
||||||
|
>
|
||||||
|
<Checkbox />
|
||||||
|
</td>
|
||||||
|
{financeTableColumns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
style={{
|
||||||
|
minWidth: col.width,
|
||||||
|
paddingInline: 16,
|
||||||
|
textAlign:
|
||||||
|
col.type === 'hours' || col.type === 'currency'
|
||||||
|
? 'center'
|
||||||
|
: 'start',
|
||||||
|
}}
|
||||||
|
className={`${customColumnHeaderStyles(col.key)} before:constent relative before:absolute before:left-0 before:top-1/2 before:h-[36px] before:w-0.5 before:-translate-y-1/2 ${themeMode === 'dark' ? 'before:bg-white/10' : 'before:bg-black/5'}`}
|
||||||
|
>
|
||||||
|
<Typography.Text>
|
||||||
|
{t(`${col.name}Column`)}{' '}
|
||||||
|
{col.type === 'currency' && `(${currency.toUpperCase()})`}
|
||||||
|
</Typography.Text>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
'#fbfbfb',
|
||||||
|
'#141414',
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
style={{
|
||||||
|
paddingInline: 16,
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
'#fbfbfb',
|
||||||
|
'#141414',
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className={customColumnStyles('totalRow')}
|
||||||
|
>
|
||||||
|
<Typography.Text style={{ fontSize: 18 }}>
|
||||||
|
{t('totalText')}
|
||||||
|
</Typography.Text>
|
||||||
|
</td>
|
||||||
|
{financeTableColumns.map(
|
||||||
|
(col) =>
|
||||||
|
(col.type === 'hours' || col.type === 'currency') && (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
style={{
|
||||||
|
minWidth: col.width,
|
||||||
|
paddingInline: 16,
|
||||||
|
textAlign: 'end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderFinancialTableHeaderContent(col.key)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{activeTablesList.map((table: any, index: number) => (
|
||||||
|
<FinanceTable
|
||||||
|
key={index}
|
||||||
|
table={table}
|
||||||
|
isScrolling={isScrolling}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{selectedTask && <FinanceDrawer task={selectedTask} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinanceTableWrapper;
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { Avatar, Checkbox, Flex, Input, Tooltip, Typography } from 'antd';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import CustomAvatar from '../../../../../../components/CustomAvatar';
|
||||||
|
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||||
|
import {
|
||||||
|
DollarCircleOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { themeWiseColor } from '../../../../../../utils/themeWiseColor';
|
||||||
|
import { colors } from '../../../../../../styles/colors';
|
||||||
|
import { financeTableColumns } from '@/lib/project/project-view-finance-table-columns';
|
||||||
|
|
||||||
|
type FinanceTableProps = {
|
||||||
|
table: any;
|
||||||
|
isScrolling: boolean;
|
||||||
|
onTaskClick: (task: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FinanceTable = ({
|
||||||
|
table,
|
||||||
|
isScrolling,
|
||||||
|
onTaskClick,
|
||||||
|
}: FinanceTableProps) => {
|
||||||
|
const [isCollapse, setIsCollapse] = useState<boolean>(false);
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
|
||||||
|
// get theme data from theme reducer
|
||||||
|
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||||
|
|
||||||
|
// totals of the current table
|
||||||
|
const totals = useMemo(
|
||||||
|
() => ({
|
||||||
|
hours: (table?.tasks || []).reduce(
|
||||||
|
(sum: any, task: { hours: any }) => sum + task.hours,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
cost: (table?.tasks || []).reduce(
|
||||||
|
(sum: any, task: { cost: any }) => sum + task.cost,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
fixedCost: (table?.tasks || []).reduce(
|
||||||
|
(sum: any, task: { fixedCost: any }) => sum + task.fixedCost,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
totalBudget: (table?.tasks || []).reduce(
|
||||||
|
(sum: any, task: { totalBudget: any }) => sum + task.totalBudget,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
totalActual: (table?.tasks || []).reduce(
|
||||||
|
(sum: any, task: { totalActual: any }) => sum + task.totalActual,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
variance: (table?.tasks || []).reduce(
|
||||||
|
(sum: any, task: { variance: any }) => sum + task.variance,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[table]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Selected Task:', selectedTask);
|
||||||
|
}, [selectedTask]);
|
||||||
|
|
||||||
|
const renderFinancialTableHeaderContent = (columnKey: any) => {
|
||||||
|
switch (columnKey) {
|
||||||
|
case 'hours':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ color: colors.darkGray }}>
|
||||||
|
{totals.hours}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'cost':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ color: colors.darkGray }}>
|
||||||
|
{totals.cost}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'fixedCost':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ color: colors.darkGray }}>
|
||||||
|
{totals.fixedCost}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'totalBudget':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ color: colors.darkGray }}>
|
||||||
|
{totals.totalBudget}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'totalActual':
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ color: colors.darkGray }}>
|
||||||
|
{totals.totalActual}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
case 'variance':
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
totals.variance < 0
|
||||||
|
? '#FF0000'
|
||||||
|
: themeWiseColor('#6DC376', colors.darkGray, themeMode),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{totals.variance}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFinancialTableColumnContent = (columnKey: any, task: any) => {
|
||||||
|
switch (columnKey) {
|
||||||
|
case 'task':
|
||||||
|
return (
|
||||||
|
<Tooltip title={task.task}>
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis={{ expanded: false }}
|
||||||
|
style={{ maxWidth: 160 }}
|
||||||
|
>
|
||||||
|
{task.task}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
{task.isbBillable && <DollarCircleOutlined />}
|
||||||
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
case 'members':
|
||||||
|
return (
|
||||||
|
<Avatar.Group>
|
||||||
|
{task.members.map((member: any) => (
|
||||||
|
<CustomAvatar avatarName={member.name} size={26} />
|
||||||
|
))}
|
||||||
|
</Avatar.Group>
|
||||||
|
);
|
||||||
|
case 'hours':
|
||||||
|
return <Typography.Text>{task.hours}</Typography.Text>;
|
||||||
|
case 'cost':
|
||||||
|
return <Typography.Text>{task.cost}</Typography.Text>;
|
||||||
|
case 'fixedCost':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={task.fixedCost}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'totalBudget':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={task.totalBudget}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
textAlign: 'right',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'totalActual':
|
||||||
|
return <Typography.Text>{task.totalActual}</Typography.Text>;
|
||||||
|
case 'variance':
|
||||||
|
return (
|
||||||
|
<Typography.Text
|
||||||
|
style={{
|
||||||
|
color: task.variance < 0 ? '#FF0000' : '#6DC376',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.variance}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// layout styles for table and the columns
|
||||||
|
const customColumnHeaderStyles = (key: string) =>
|
||||||
|
`px-2 text-left ${key === 'tableTitle' && `sticky left-0 z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`}`;
|
||||||
|
|
||||||
|
const customColumnStyles = (key: string) =>
|
||||||
|
`px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && 'sticky left-[48px] z-10'} ${key === 'members' && `sticky left-[288px] z-10 ${isScrolling ? 'after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[52px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* header row */}
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
table.color_code,
|
||||||
|
table.color_code_dark,
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
textAlign: 'left',
|
||||||
|
paddingInline: 16,
|
||||||
|
backgroundColor: themeWiseColor(
|
||||||
|
table.color_code,
|
||||||
|
table.color_code_dark,
|
||||||
|
themeMode
|
||||||
|
),
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
className={customColumnHeaderStyles('tableTitle')}
|
||||||
|
onClick={(e) => setIsCollapse((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Flex gap={8} align="center" style={{ color: colors.darkGray }}>
|
||||||
|
{isCollapse ? <RightOutlined /> : <DownOutlined />}
|
||||||
|
{table.name} ({table.tasks.length})
|
||||||
|
</Flex>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{financeTableColumns.map(
|
||||||
|
(col) =>
|
||||||
|
col.key !== 'task' &&
|
||||||
|
col.key !== 'members' && (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
style={{
|
||||||
|
width: col.width,
|
||||||
|
paddingInline: 16,
|
||||||
|
textAlign: 'end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderFinancialTableHeaderContent(col.key)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* task rows */}
|
||||||
|
{table.tasks.map((task: any) => (
|
||||||
|
<tr
|
||||||
|
key={task.taskId}
|
||||||
|
style={{ height: 52 }}
|
||||||
|
className={`${isCollapse ? 'hidden' : 'static'} cursor-pointer border-b-[1px] ${themeMode === 'dark' ? 'hover:bg-[#000000]' : 'hover:bg-[#f8f7f9]'} `}
|
||||||
|
onClick={() => onTaskClick(task)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{ paddingInline: 16 }}
|
||||||
|
className={customColumnStyles('selector')}
|
||||||
|
>
|
||||||
|
<Checkbox />
|
||||||
|
</td>
|
||||||
|
{financeTableColumns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={customColumnStyles(col.key)}
|
||||||
|
style={{
|
||||||
|
width: col.width,
|
||||||
|
paddingInline: 16,
|
||||||
|
textAlign:
|
||||||
|
col.type === 'hours' || col.type === 'currency'
|
||||||
|
? 'end'
|
||||||
|
: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderFinancialTableColumnContent(col.key, task)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinanceTable;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { CaretDownFilled } from '@ant-design/icons';
|
||||||
|
import { Flex, Select } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { useSelectedProject } from '../../../../../hooks/useSelectedProject';
|
||||||
|
import { useAppSelector } from '../../../../../hooks/useAppSelector';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type GroupByFilterDropdownProps = {
|
||||||
|
activeGroup: 'status' | 'priority' | 'phases';
|
||||||
|
setActiveGroup: (group: 'status' | 'priority' | 'phases') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupByFilterDropdown = ({
|
||||||
|
activeGroup,
|
||||||
|
setActiveGroup,
|
||||||
|
}: GroupByFilterDropdownProps) => {
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
setActiveGroup(value as 'status' | 'priority' | 'phases');
|
||||||
|
};
|
||||||
|
|
||||||
|
// get selected project from useSelectedPro
|
||||||
|
const selectedProject = useSelectedProject();
|
||||||
|
|
||||||
|
//get phases details from phases slice
|
||||||
|
const phase =
|
||||||
|
useAppSelector((state) => state.phaseReducer.phaseList).find(
|
||||||
|
(phase) => phase?.projectId === selectedProject?.projectId
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
const groupDropdownMenuItems = [
|
||||||
|
{ key: 'status', value: 'status', label: t('statusText') },
|
||||||
|
{ key: 'priority', value: 'priority', label: t('priorityText') },
|
||||||
|
{
|
||||||
|
key: 'phase',
|
||||||
|
value: 'phase',
|
||||||
|
label: phase ? phase?.phase : t('phaseText'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
|
||||||
|
{t('groupByText')}:
|
||||||
|
<Select
|
||||||
|
defaultValue={'status'}
|
||||||
|
options={groupDropdownMenuItems}
|
||||||
|
onChange={handleChange}
|
||||||
|
suffixIcon={<CaretDownFilled />}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupByFilterDropdown;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Button, ConfigProvider, Flex, Select, Typography } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import GroupByFilterDropdown from './group-by-filter-dropdown';
|
||||||
|
import { DownOutlined } from '@ant-design/icons';
|
||||||
|
import { useAppDispatch } from '../../../../../hooks/useAppDispatch';
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { changeCurrency, toggleImportRatecardsDrawer } from '@/features/finance/finance-slice';
|
||||||
|
|
||||||
|
type ProjectViewFinanceHeaderProps = {
|
||||||
|
activeTab: 'finance' | 'ratecard';
|
||||||
|
setActiveTab: (tab: 'finance' | 'ratecard') => void;
|
||||||
|
activeGroup: 'status' | 'priority' | 'phases';
|
||||||
|
setActiveGroup: (group: 'status' | 'priority' | 'phases') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectViewFinanceHeader = ({
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
activeGroup,
|
||||||
|
setActiveGroup,
|
||||||
|
}: ProjectViewFinanceHeaderProps) => {
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider wave={{ disabled: true }}>
|
||||||
|
<Flex gap={16} align="center" justify="space-between">
|
||||||
|
<Flex gap={16} align="center">
|
||||||
|
<Flex>
|
||||||
|
<Button
|
||||||
|
className={`${activeTab === 'finance' && 'border-[#1890ff] text-[#1890ff]'} rounded-r-none`}
|
||||||
|
onClick={() => setActiveTab('finance')}
|
||||||
|
>
|
||||||
|
{t('financeText')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={`${activeTab === 'ratecard' && 'border-[#1890ff] text-[#1890ff]'} rounded-l-none`}
|
||||||
|
onClick={() => setActiveTab('ratecard')}
|
||||||
|
>
|
||||||
|
{t('ratecardSingularText')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{activeTab === 'finance' && (
|
||||||
|
<GroupByFilterDropdown
|
||||||
|
activeGroup={activeGroup}
|
||||||
|
setActiveGroup={setActiveGroup}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{activeTab === 'finance' ? (
|
||||||
|
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||||
|
{t('exportButton')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Flex gap={8} align="center">
|
||||||
|
<Typography.Text>{t('currencyText')}</Typography.Text>
|
||||||
|
<Select
|
||||||
|
defaultValue={'lkr'}
|
||||||
|
options={[
|
||||||
|
{ value: 'lkr', label: 'LKR' },
|
||||||
|
{ value: 'usd', label: 'USD' },
|
||||||
|
{ value: 'inr', label: 'INR' },
|
||||||
|
]}
|
||||||
|
onChange={(value) => dispatch(changeCurrency(value))}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => dispatch(toggleImportRatecardsDrawer())}
|
||||||
|
>
|
||||||
|
{t('importButton')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectViewFinanceHeader;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Flex } from 'antd';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import ProjectViewFinanceHeader from './project-view-finance-header/project-view-finance-header';
|
||||||
|
import FinanceTab from './finance-tab/finance-tab';
|
||||||
|
import RatecardTab from './ratecard-tab/ratecard-tab';
|
||||||
|
|
||||||
|
type FinanceTabType = 'finance' | 'ratecard';
|
||||||
|
type GroupTypes = 'status' | 'priority' | 'phases';
|
||||||
|
|
||||||
|
const ProjectViewFinance = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<FinanceTabType>('finance');
|
||||||
|
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
|
<ProjectViewFinanceHeader
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
activeGroup={activeGroup}
|
||||||
|
setActiveGroup={setActiveGroup}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeTab === 'finance' ? (
|
||||||
|
<FinanceTab groupType={activeGroup} />
|
||||||
|
) : (
|
||||||
|
<RatecardTab />
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectViewFinance;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import RatecardTable from './reatecard-table/ratecard-table';
|
||||||
|
import { Button, Flex, Typography } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import ImportRatecardsDrawer from '@/features/finance/ratecard-drawer/import-ratecards-drawer';
|
||||||
|
|
||||||
|
const RatecardTab = () => {
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={8}>
|
||||||
|
<RatecardTable />
|
||||||
|
|
||||||
|
<Typography.Text
|
||||||
|
type="danger"
|
||||||
|
style={{ display: 'block', marginTop: '10px' }}
|
||||||
|
>
|
||||||
|
{t('ratecardImportantNotice')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
width: 'fit-content',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('saveButton')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* import ratecards drawer */}
|
||||||
|
<ImportRatecardsDrawer />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RatecardTab;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Avatar, Button, Input, Table, TableProps } from 'antd';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import CustomAvatar from '../../../../../../components/CustomAvatar';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { JobRoleType } from '@/types/project/ratecard.types';
|
||||||
|
|
||||||
|
const initialJobRolesList: JobRoleType[] = [
|
||||||
|
{
|
||||||
|
jobId: 'J001',
|
||||||
|
jobTitle: 'Project Manager',
|
||||||
|
ratePerHour: 50,
|
||||||
|
members: ['Alice Johnson', 'Bob Smith'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: 'J002',
|
||||||
|
jobTitle: 'Senior Software Engineer',
|
||||||
|
ratePerHour: 40,
|
||||||
|
members: ['Charlie Brown', 'Diana Prince'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: 'J003',
|
||||||
|
jobTitle: 'Junior Software Engineer',
|
||||||
|
ratePerHour: 25,
|
||||||
|
members: ['Eve Davis', 'Frank Castle'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: 'J004',
|
||||||
|
jobTitle: 'UI/UX Designer',
|
||||||
|
ratePerHour: 30,
|
||||||
|
members: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const RatecardTable: React.FC = () => {
|
||||||
|
const [roles, setRoles] = useState<JobRoleType[]>(initialJobRolesList);
|
||||||
|
|
||||||
|
// localization
|
||||||
|
const { t } = useTranslation('project-view-finance');
|
||||||
|
|
||||||
|
// get currently using currency from finance reducer
|
||||||
|
const currency = useAppSelector(
|
||||||
|
(state) => state.financeReducer.currency
|
||||||
|
).toUpperCase();
|
||||||
|
|
||||||
|
const handleAddRole = () => {
|
||||||
|
const newRole: JobRoleType = {
|
||||||
|
jobId: `J00${roles.length + 1}`,
|
||||||
|
jobTitle: 'New Role',
|
||||||
|
ratePerHour: 0,
|
||||||
|
members: [],
|
||||||
|
};
|
||||||
|
setRoles([...roles, newRole]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableProps<JobRoleType>['columns'] = [
|
||||||
|
{
|
||||||
|
title: t('jobTitleColumn'),
|
||||||
|
dataIndex: 'jobTitle',
|
||||||
|
render: (text: string, record: JobRoleType, index: number) => (
|
||||||
|
<Input
|
||||||
|
value={text}
|
||||||
|
placeholder="Enter job title"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
padding: 0,
|
||||||
|
color: '#1890ff',
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedRoles = [...roles];
|
||||||
|
updatedRoles[index].jobTitle = e.target.value;
|
||||||
|
setRoles(updatedRoles);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${t('ratePerHourColumn')} (${currency})`,
|
||||||
|
dataIndex: 'ratePerHour',
|
||||||
|
render: (text: number, record: JobRoleType, index: number) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={text}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedRoles = [...roles];
|
||||||
|
updatedRoles[index].ratePerHour = parseInt(e.target.value, 10) || 0;
|
||||||
|
setRoles(updatedRoles);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('membersColumn'),
|
||||||
|
dataIndex: 'members',
|
||||||
|
render: (members: string[]) =>
|
||||||
|
members?.length > 0 ? (
|
||||||
|
<Avatar.Group>
|
||||||
|
{members.map((member, i) => (
|
||||||
|
<CustomAvatar key={i} avatarName={member} size={26} />
|
||||||
|
))}
|
||||||
|
</Avatar.Group>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
icon={
|
||||||
|
<PlusOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={roles}
|
||||||
|
columns={columns}
|
||||||
|
rowKey={(record) => record.jobId}
|
||||||
|
pagination={false}
|
||||||
|
footer={() => (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={handleAddRole}
|
||||||
|
style={{ width: 'fit-content' }}
|
||||||
|
>
|
||||||
|
{t('addRoleButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RatecardTable;
|
||||||
6
worklenz-frontend/src/types/project/job.types.ts
Normal file
6
worklenz-frontend/src/types/project/job.types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type JobType = {
|
||||||
|
jobId: string;
|
||||||
|
jobTitle: string;
|
||||||
|
ratePerHour?: number;
|
||||||
|
};
|
||||||
|
|
||||||
14
worklenz-frontend/src/types/project/ratecard.types.ts
Normal file
14
worklenz-frontend/src/types/project/ratecard.types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { JobType } from "./job.types";
|
||||||
|
|
||||||
|
|
||||||
|
export interface JobRoleType extends JobType {
|
||||||
|
members: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RatecardType = {
|
||||||
|
ratecardId: string;
|
||||||
|
ratecardName: string;
|
||||||
|
jobRolesList: JobType[];
|
||||||
|
createdDate: Date;
|
||||||
|
currency?: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user