Merge pull request #137 from shancds/feature/project-finance

Feature/project finance (project-ratecard-member-add)
This commit is contained in:
Chamika J
2025-05-26 08:33:29 +05:30
committed by GitHub
11 changed files with 457 additions and 131 deletions

View File

@@ -45,4 +45,4 @@ ALTER TABLE projects
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL; ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
ALTER TABLE finance_rate_cards ALTER TABLE finance_rate_cards
ADD COLUMN currency TEXT NOT NULL DEFAULT 'LKR'; ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';

View File

@@ -6,9 +6,27 @@ import HandleExceptions from "../decorators/handle-exceptions";
import WorklenzControllerBase from "./worklenz-controller-base"; import WorklenzControllerBase from "./worklenz-controller-base";
export default class ProjectRateCardController extends WorklenzControllerBase { export default class ProjectRateCardController extends WorklenzControllerBase {
// Insert a single role for a project
@HandleExceptions()
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, job_title_id, rate } = req.body;
if (!project_id || !job_title_id || typeof rate !== "number") {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
}
const q = `
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
VALUES ($1, $2, $3)
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
RETURNING *,
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
`;
const result = await db.query(q, [project_id, job_title_id, rate]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
// Insert multiple roles for a project // Insert multiple roles for a project
@HandleExceptions() @HandleExceptions()
public static async insertMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body; const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) { if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input")); return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
@@ -32,10 +50,17 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
// Get all roles for a project // Get all roles for a project
@HandleExceptions() @HandleExceptions()
public static async getFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params; const { project_id } = req.params;
const q = ` const q = `
SELECT fprr.*, jt.name as jobtitle SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.project_id = $1 WHERE fprr.project_id = $1
@@ -47,10 +72,17 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
// Get a single role by id // Get a single role by id
@HandleExceptions() @HandleExceptions()
public static async getFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params; const { id } = req.params;
const q = ` const q = `
SELECT fprr.*, jt.name as jobtitle SELECT
fprr.*,
jt.name as jobtitle,
(
SELECT COALESCE(json_agg(pm.id), '[]'::json)
FROM project_members pm
WHERE pm.project_rate_card_role_id = fprr.id
) AS members
FROM finance_project_rate_card_roles fprr FROM finance_project_rate_card_roles fprr
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
WHERE fprr.id = $1; WHERE fprr.id = $1;
@@ -61,7 +93,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
// Update a single role by id // Update a single role by id
@HandleExceptions() @HandleExceptions()
public static async updateFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params; const { id } = req.params;
const { job_title_id, rate } = req.body; const { job_title_id, rate } = req.body;
const q = ` const q = `
@@ -74,29 +106,120 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, result.rows[0])); return res.status(200).send(new ServerResponse(true, result.rows[0]));
} }
// update project member rate for a project with members
@HandleExceptions()
public static async updateProjectMemberByProjectIdAndMemberId(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { project_id, id } = req.params;
const { project_rate_card_role_id } = req.body;
if (!project_id || !id || !project_rate_card_role_id) {
return res.status(400).send(new ServerResponse(false, null, "Missing values"));
}
try {
// Step 1: Check current role assignment
const checkQuery = `
SELECT project_rate_card_role_id
FROM project_members
WHERE id = $1 AND project_id = $2;
`;
const { rows: checkRows } = await db.query(checkQuery, [id, project_id]);
const currentRoleId = checkRows[0]?.project_rate_card_role_id;
if (currentRoleId !== null && currentRoleId !== project_rate_card_role_id) {
// Step 2: Fetch members with the requested role
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: memberRows } = await db.query(membersQuery, [project_id, project_rate_card_role_id]);
return res.status(200).send(
new ServerResponse(false, memberRows[0], "Already Assigned !")
);
}
// Step 3: Perform the update
const updateQuery = `
UPDATE project_members
SET project_rate_card_role_id = CASE
WHEN project_rate_card_role_id = $1 THEN NULL
ELSE $1
END
WHERE id = $2
AND project_id = $3
AND EXISTS (
SELECT 1
FROM finance_project_rate_card_roles
WHERE id = $1 AND project_id = $3
)
RETURNING project_rate_card_role_id;
`;
const { rows: updateRows } = await db.query(updateQuery, [project_rate_card_role_id, id, project_id]);
if (updateRows.length === 0) {
return res.status(200).send(new ServerResponse(true, [], "Project member not found or invalid project_rate_card_role_id"));
}
const updatedRoleId = updateRows[0].project_rate_card_role_id || project_rate_card_role_id;
// Step 4: Fetch updated members list
const membersQuery = `
SELECT COALESCE(json_agg(id), '[]'::json) AS members
FROM project_members
WHERE project_id = $1 AND project_rate_card_role_id = $2;
`;
const { rows: finalMembers } = await db.query(membersQuery, [project_id, updatedRoleId]);
return res.status(200).send(new ServerResponse(true, finalMembers[0]));
} catch (error) {
return res.status(500).send(new ServerResponse(false, null, "Internal server error"));
}
}
// Update all roles for a project (delete then insert) // Update all roles for a project (delete then insert)
@HandleExceptions() @HandleExceptions()
public static async updateFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id, roles } = req.body; const { project_id, roles } = req.body;
if (!Array.isArray(roles) || !project_id) { if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input")); return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
} }
// Delete existing
await db.query(`DELETE FROM finance_project_rate_card_roles WHERE project_id = $1`, [project_id]);
// Insert new
if (roles.length === 0) { if (roles.length === 0) {
// If no roles provided, do nothing and return empty array
return res.status(200).send(new ServerResponse(true, [])); return res.status(200).send(new ServerResponse(true, []));
} }
// Build upsert query for all roles
const values = roles.map((role: any) => [ const values = roles.map((role: any) => [
project_id, project_id,
role.job_title_id, role.job_title_id,
role.rate role.rate
]); ]);
const q = ` const q = `
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) WITH upserted AS (
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")} INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
RETURNING *, VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle; ON CONFLICT (project_id, job_title_id)
DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW()
RETURNING *
),
jobtitles AS (
SELECT upr.*, jt.name AS jobtitle
FROM upserted upr
JOIN job_titles jt ON jt.id = upr.job_title_id
),
members AS (
SELECT json_agg(pm.id) AS members, pm.project_rate_card_role_id
FROM project_members pm
WHERE pm.project_rate_card_role_id IN (SELECT id FROM jobtitles)
GROUP BY pm.project_rate_card_role_id
)
SELECT jt.*, m.members
FROM jobtitles jt
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
`; `;
const flatValues = values.flat(); const flatValues = values.flat();
const result = await db.query(q, flatValues); const result = await db.query(q, flatValues);
@@ -105,7 +228,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
// Delete a single role by id // Delete a single role by id
@HandleExceptions() @HandleExceptions()
public static async deleteFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params; const { id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`; const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
const result = await db.query(q, [id]); const result = await db.query(q, [id]);
@@ -114,7 +237,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
// Delete all roles for a project // Delete all roles for a project
@HandleExceptions() @HandleExceptions()
public static async deleteFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> { public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params; const { project_id } = req.params;
const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`; const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
const result = await db.query(q, [project_id]); const result = await db.query(q, [project_id]);

View File

@@ -10,46 +10,60 @@ const projectRatecardApiRouter = express.Router();
projectRatecardApiRouter.post( projectRatecardApiRouter.post(
"/", "/",
projectManagerValidator, projectManagerValidator,
safeControllerFunction(ProjectRateCardController.insertMany) safeControllerFunction(ProjectRateCardController.createMany)
);
// Insert a single role for a project
projectRatecardApiRouter.post(
"/create-project-rate-card-role",
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.createOne)
); );
// Get all roles for a project // Get all roles for a project
projectRatecardApiRouter.get( projectRatecardApiRouter.get(
"/project/:project_id", "/project/:project_id",
safeControllerFunction(ProjectRateCardController.getFromProjectId) safeControllerFunction(ProjectRateCardController.getByProjectId)
); );
// Get a single role by id // Get a single role by id
projectRatecardApiRouter.get( projectRatecardApiRouter.get(
"/:id", "/:id",
idParamValidator, idParamValidator,
safeControllerFunction(ProjectRateCardController.getFromId) safeControllerFunction(ProjectRateCardController.getById)
); );
// Update a single role by id // Update a single role by id
projectRatecardApiRouter.put( projectRatecardApiRouter.put(
"/:id", "/:id",
idParamValidator, idParamValidator,
safeControllerFunction(ProjectRateCardController.updateFromId) safeControllerFunction(ProjectRateCardController.updateById)
); );
// Update all roles for a project (delete then insert) // Update all roles for a project (delete then insert)
projectRatecardApiRouter.put( projectRatecardApiRouter.put(
"/project/:project_id", "/project/:project_id",
safeControllerFunction(ProjectRateCardController.updateFromProjectId) safeControllerFunction(ProjectRateCardController.updateByProjectId)
);
// Update project member rate card role
projectRatecardApiRouter.put(
"/project/:project_id/members/:id/rate-card-role",
idParamValidator,
projectManagerValidator,
safeControllerFunction(ProjectRateCardController.updateProjectMemberByProjectIdAndMemberId)
); );
// Delete a single role by id // Delete a single role by id
projectRatecardApiRouter.delete( projectRatecardApiRouter.delete(
"/:id", "/:id",
idParamValidator, idParamValidator,
safeControllerFunction(ProjectRateCardController.deleteFromId) safeControllerFunction(ProjectRateCardController.deleteById)
); );
// Delete all roles for a project // Delete all roles for a project
projectRatecardApiRouter.delete( projectRatecardApiRouter.delete(
"/project/:project_id", "/project/:project_id",
safeControllerFunction(ProjectRateCardController.deleteFromProjectId) safeControllerFunction(ProjectRateCardController.deleteByProjectId)
); );
export default projectRatecardApiRouter; export default projectRatecardApiRouter;

View File

@@ -28,6 +28,10 @@
"jobTitleColumn": "Job Title", "jobTitleColumn": "Job Title",
"ratePerHourColumn": "Rate per hour", "ratePerHourColumn": "Rate per hour",
"ratecardPluralText": "Rate Cards", "ratecardPluralText": "Rate Cards",
"labourHoursColumn": "Labour Hours" "labourHoursColumn": "Labour Hours",
"actions": "Actions",
"selectJobTitle": "Select Job Title",
"ratecardsPluralText": "Rate Card Templates"
} }

View File

@@ -19,6 +19,7 @@
"currency": "Currency", "currency": "Currency",
"actionsColumn": "Actions", "actionsColumn": "Actions",
"addAllButton": "Add All", "addAllButton": "Add All",
"removeAllButton": "Remove All" "removeAllButton": "Remove All",
"selectJobTitle": "Select job title"
} }

View File

@@ -1,7 +1,7 @@
import apiClient from '@api/api-client'; import apiClient from '@api/api-client';
import { API_BASE_URL } from '@/shared/constants'; import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types'; import { IServerResponse } from '@/types/common.types';
import { IJobType } from '@/types/project/ratecard.types'; import { IJobType, JobRoleType } from '@/types/project/ratecard.types';
const rootUrl = `${API_BASE_URL}/project-rate-cards`; const rootUrl = `${API_BASE_URL}/project-rate-cards`;
@@ -21,6 +21,14 @@ export const projectRateCardApiService = {
const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, { project_id, roles }); const response = await apiClient.post<IServerResponse<IProjectRateCardRole[]>>(rootUrl, { project_id, roles });
return response.data; return response.data;
}, },
// Insert a single role for a project
async insertOne({ project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number }): Promise<IServerResponse<IProjectRateCardRole>> {
const response = await apiClient.post<IServerResponse<IProjectRateCardRole>>(
`${rootUrl}/create-project-rate-card-role`,
{ project_id, job_title_id, rate }
);
return response.data;
},
// Get all roles for a project // Get all roles for a project
async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> { async getFromProjectId(project_id: string): Promise<IServerResponse<IProjectRateCardRole[]>> {
@@ -46,6 +54,19 @@ export const projectRateCardApiService = {
return response.data; return response.data;
}, },
// Update project member rate card role
async updateMemberRateCardRole(
project_id: string,
member_id: string,
project_rate_card_role_id: string
): Promise<IServerResponse<JobRoleType>> {
const response = await apiClient.put<IServerResponse<JobRoleType>>(
`${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`,
{ project_rate_card_role_id }
);
return response.data;
},
// Delete a single role by id // Delete a single role by id
async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> { async deleteFromId(id: string): Promise<IServerResponse<IProjectRateCardRole>> {
const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`); const response = await apiClient.delete<IServerResponse<IProjectRateCardRole>>(`${rootUrl}/${id}`);

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import { InputRef } from 'antd/es/input';
import Dropdown from 'antd/es/dropdown';
import Card from 'antd/es/card';
import List from 'antd/es/list';
import Input from 'antd/es/input';
import Checkbox from 'antd/es/checkbox';
import Button from 'antd/es/button';
import Empty from 'antd/es/empty';
import { PlusOutlined } from '@ant-design/icons';
import SingleAvatar from '../common/single-avatar/single-avatar';
import { IProjectMemberViewModel } from '@/types/projectMember.types';
interface RateCardAssigneeSelectorProps {
projectId: string;
onChange?: (memberId: string) => void;
selectedMemberIds?: string[];
memberlist?: IProjectMemberViewModel[];
}
const RateCardAssigneeSelector = ({
projectId,
onChange,
selectedMemberIds = [],
memberlist = [],
}: RateCardAssigneeSelectorProps) => {
const membersInputRef = useRef<InputRef>(null);
const [searchQuery, setSearchQuery] = useState('');
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist);
useEffect(() => {
setMembers(memberlist);
}, [memberlist]);
const filteredMembers = members.filter((member) =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const dropdownContent = (
<Card styles={{ body: { padding: 8 } }}>
<Input
ref={membersInputRef}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Search members"
/>
<List style={{ padding: 0, maxHeight: 200, overflow: 'auto' }}>
{filteredMembers.length ? (
filteredMembers.map((member) => (
<List.Item
key={member.id}
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
padding: '4px 8px',
border: 'none',
opacity: member.pending_invitation ? 0.5 : 1,
justifyContent: 'flex-start',
textAlign: 'left',
}}
>
<Checkbox
checked={selectedMemberIds.includes(member.id || '')}
disabled={member.pending_invitation}
onChange={() => onChange?.(member.id || '')}
/>
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
<span>{member.name}</span>
</List.Item>
))
) : (
<Empty description="No members found" />
)}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => dropdownContent}
onOpenChange={(open) => {
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
}}
>
<Button
type="dashed"
shape="circle"
size="small"
icon={<PlusOutlined style={{ fontSize: 12 }} />}
/>
</Dropdown>
);
};
export default RateCardAssigneeSelector;

View File

@@ -18,7 +18,7 @@ const initialState: financeState = {
isRatecardDrawerOpen: false, isRatecardDrawerOpen: false,
isFinanceDrawerOpen: false, isFinanceDrawerOpen: false,
isImportRatecardsDrawerOpen: false, isImportRatecardsDrawerOpen: false,
currency: 'LKR', currency: 'USD',
isRatecardsLoading: false, isRatecardsLoading: false,
isFinanceDrawerloading: false, isFinanceDrawerloading: false,
drawerRatecard: null, drawerRatecard: null,

View File

@@ -25,7 +25,6 @@ export const fetchProjectRateCardRoles = createAsyncThunk(
async (project_id: string, { rejectWithValue }) => { async (project_id: string, { rejectWithValue }) => {
try { try {
const response = await projectRateCardApiService.getFromProjectId(project_id); const response = await projectRateCardApiService.getFromProjectId(project_id);
console.log('Project RateCard Roles:', response);
return response.body; return response.body;
} catch (error) { } catch (error) {
logger.error('Fetch Project RateCard Roles', error); logger.error('Fetch Project RateCard Roles', error);
@@ -63,6 +62,23 @@ export const insertProjectRateCardRoles = createAsyncThunk(
} }
); );
export const insertProjectRateCardRole = createAsyncThunk(
'projectFinance/insertOne',
async (
{ project_id, job_title_id, rate }: { project_id: string; job_title_id: string; rate: number },
{ rejectWithValue }
) => {
try {
const response = await projectRateCardApiService.insertOne({ project_id, job_title_id, rate });
return response.body;
} catch (error) {
logger.error('Insert Project RateCard Role', error);
if (error instanceof Error) return rejectWithValue(error.message);
return rejectWithValue('Failed to insert project rate card role');
}
}
);
export const updateProjectRateCardRoleById = createAsyncThunk( export const updateProjectRateCardRoleById = createAsyncThunk(
'projectFinance/updateById', 'projectFinance/updateById',
async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => { async ({ id, body }: { id: string; body: { job_title_id: string; rate: string } }, { rejectWithValue }) => {
@@ -105,6 +121,14 @@ export const deleteProjectRateCardRoleById = createAsyncThunk(
} }
); );
export const assignMemberToRateCardRole = createAsyncThunk(
'projectFinance/assignMemberToRateCardRole',
async ({ project_id, member_id, project_rate_card_role_id }: { project_id: string; member_id: string; project_rate_card_role_id: string }) => {
const response = await projectRateCardApiService.updateMemberRateCardRole(project_id, member_id, project_rate_card_role_id);
return response.body;
}
);
export const deleteProjectRateCardRolesByProjectId = createAsyncThunk( export const deleteProjectRateCardRolesByProjectId = createAsyncThunk(
'projectFinance/deleteByProjectId', 'projectFinance/deleteByProjectId',
async (project_id: string, { rejectWithValue }) => { async (project_id: string, { rejectWithValue }) => {

View File

@@ -43,7 +43,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('LKR'); const [currency, setCurrency] = useState('USD');
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>({
@@ -95,7 +95,7 @@ const RatecardDrawer = ({
if (type === 'update' && drawerRatecard) { if (type === 'update' && drawerRatecard) {
setRoles(drawerRatecard.jobRolesList || []); setRoles(drawerRatecard.jobRolesList || []);
setName(drawerRatecard.name || ''); setName(drawerRatecard.name || '');
setCurrency(drawerRatecard.currency || 'LKR'); setCurrency(drawerRatecard.currency || 'USD');
} }
}, [drawerRatecard, type]); }, [drawerRatecard, type]);
@@ -121,15 +121,17 @@ const RatecardDrawer = ({
setRoles(mergedRoles); setRoles(mergedRoles);
}; };
const handleAddRole = () => { const handleAddRole = () => {
// Only allow adding if there are job titles not already in roles
const existingIds = new Set(roles.map(r => r.job_title_id)); const existingIds = new Set(roles.map(r => r.job_title_id));
const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!)); const availableJobTitles = jobTitles.data?.filter(jt => !existingIds.has(jt.id!));
if (availableJobTitles && availableJobTitles.length > 0) { if (availableJobTitles && availableJobTitles.length > 0) {
setRoles([...roles, { job_title_id: '', rate: 0 }]); setRoles([...roles, { job_title_id: '', rate: 0 }]);
setAddingRowIndex(roles.length); // index of the new row setAddingRowIndex(roles.length);
setIsAddingRole(true);
} }
}; };
const handleDeleteRole = (index: number) => { const handleDeleteRole = (index: number) => {
const updatedRoles = [...roles]; const updatedRoles = [...roles];
updatedRoles.splice(index, 1); updatedRoles.splice(index, 1);
@@ -185,7 +187,7 @@ const RatecardDrawer = ({
} finally { } finally {
setRoles([]); setRoles([]);
setName('Untitled Rate Card'); setName('Untitled Rate Card');
setCurrency('LKR'); setCurrency('USD');
} }
} }
}; };
@@ -218,6 +220,9 @@ const RatecardDrawer = ({
setAddingRowIndex(null); setAddingRowIndex(null);
}} }}
onBlur={() => { onBlur={() => {
if (roles[index].job_title_id === ""){
handleDeleteRole(index);
}
setEditingRowIndex(null); setEditingRowIndex(null);
setAddingRowIndex(null); setAddingRowIndex(null);
}} }}
@@ -239,7 +244,7 @@ const RatecardDrawer = ({
return ( return (
<span <span
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => setEditingRowIndex(index)} // onClick={() => setEditingRowIndex(index)}
> >
{record.jobtitle} {record.jobtitle}
</span> </span>
@@ -249,6 +254,7 @@ const RatecardDrawer = ({
{ {
title: `${t('ratePerHourColumn')} (${currency})`, title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate', dataIndex: 'rate',
align: 'right',
render: (text: number, record: any, index: number) => ( render: (text: number, record: any, index: number) => (
<Input <Input
type="number" type="number"
@@ -257,6 +263,7 @@ const RatecardDrawer = ({
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
boxShadow: 'none', boxShadow: 'none',
textAlign: 'right',
padding: 0, padding: 0,
}} }}
onChange={(e) => { onChange={(e) => {
@@ -319,8 +326,8 @@ const RatecardDrawer = ({
<Select <Select
value={currency} value={currency}
options={[ options={[
{ value: 'LKR', label: 'LKR' },
{ value: 'USD', label: 'USD' }, { value: 'USD', label: 'USD' },
{ value: 'LKR', label: 'LKR' },
{ value: 'INR', label: 'INR' }, { value: 'INR', label: 'INR' },
]} ]}
onChange={(value) => setCurrency(value)} onChange={(value) => setCurrency(value)}
@@ -347,27 +354,7 @@ const RatecardDrawer = ({
rowKey={(record) => record.job_title_id} rowKey={(record) => record.job_title_id}
pagination={false} pagination={false}
footer={() => ( footer={() => (
isAddingRole ? ( <Button
<Select
showSearch
style={{ minWidth: 200 }}
placeholder={t('selectJobTitle')}
optionFilterProp="children"
value={selectedJobTitleId}
onChange={handleSelectJobTitle}
onBlur={() => setIsAddingRole(false)}
filterOption={(input, option) =>
(option?.children as string).toLowerCase().includes(input.toLowerCase())
}
>
{jobTitles.data?.map((jt) => (
<Select.Option key={jt.id} value={jt.id}>
{jt.name}
</Select.Option>
))}
</Select>
) : (
<Button
type="dashed" type="dashed"
onClick={handleAddRole} onClick={handleAddRole}
block block
@@ -375,7 +362,6 @@ const RatecardDrawer = ({
> >
{t('addRoleButton')} {t('addRoleButton')}
</Button> </Button>
)
)} )}
/> />
</Drawer> </Drawer>

View File

@@ -7,12 +7,17 @@ import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { JobRoleType, IJobType, RatecardType } from '@/types/project/ratecard.types'; import { JobRoleType, IJobType, RatecardType } from '@/types/project/ratecard.types';
import { import {
assignMemberToRateCardRole,
deleteProjectRateCardRoleById, deleteProjectRateCardRoleById,
fetchProjectRateCardRoles, fetchProjectRateCardRoles,
insertProjectRateCardRole,
updateProjectRateCardRolesByProjectId, updateProjectRateCardRolesByProjectId,
} from '@/features/finance/project-finance-slice'; } from '@/features/finance/project-finance-slice';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service'; import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
import RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-assignee-selector';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { IProjectMemberViewModel } from '@/types/projectMember.types';
const RatecardTable: React.FC = () => { const RatecardTable: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -27,8 +32,42 @@ const RatecardTable: React.FC = () => {
// Local state for editing // Local state for editing
const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux); const [roles, setRoles] = useState<JobRoleType[]>(rolesRedux);
const [addingRow, setAddingRow] = useState<boolean>(false); const [addingRow, setAddingRow] = useState<boolean>(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]); const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
const [members, setMembers] = useState<IProjectMemberViewModel[]>([]);
const [isLoadingMembers, setIsLoading] = useState(false);
const pagination = {
current: 1,
pageSize: 1000,
field: 'name',
order: 'asc',
};
const getProjectMembers = async () => {
if (!projectId) return;
setIsLoading(true);
try {
const res = await projectsApiService.getMembers(
projectId,
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
null
);
if (res.done) {
setMembers(res.body?.data || []);
}
} catch (error) {
console.error('Error fetching members:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
getProjectMembers();
}, [projectId]);
// Fetch job titles for selection // Fetch job titles for selection
useEffect(() => { useEffect(() => {
@@ -40,7 +79,6 @@ const RatecardTable: React.FC = () => {
// Sync local roles with redux roles // Sync local roles with redux roles
useEffect(() => { useEffect(() => {
console.log('Roles Redux:', rolesRedux);
setRoles(rolesRedux); setRoles(rolesRedux);
}, [rolesRedux]); }, [rolesRedux]);
@@ -59,7 +97,6 @@ const RatecardTable: React.FC = () => {
// Save all roles (bulk update) // Save all roles (bulk update)
const handleSaveAll = () => { const handleSaveAll = () => {
if (projectId) { if (projectId) {
// Only send roles with job_title_id and rate
const filteredRoles = roles const filteredRoles = roles
.filter((r) => r.job_title_id && typeof r.rate !== 'undefined') .filter((r) => r.job_title_id && typeof r.rate !== 'undefined')
.map((r) => ({ .map((r) => ({
@@ -72,20 +109,27 @@ const RatecardTable: React.FC = () => {
}; };
// Handle job title select for new row // Handle job title select for new row
const handleSelectJobTitle = (jobTitleId: string) => { const handleSelectJobTitle = async (jobTitleId: string) => {
const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId); const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId);
if (!jobTitle) return; if (!jobTitle || !projectId) return;
// Prevent duplicates
if (roles.some((r) => r.job_title_id === jobTitleId)) return; if (roles.some((r) => r.job_title_id === jobTitleId)) return;
setRoles([ const resultAction = await dispatch(
...roles, insertProjectRateCardRole({ project_id: projectId, job_title_id: jobTitleId, rate: 0 })
{ );
job_title_id: jobTitleId,
jobtitle: jobTitle.name || '', if (insertProjectRateCardRole.fulfilled.match(resultAction)) {
rate: 0, const newRole = resultAction.payload;
members: [], setRoles([
}, ...roles,
]); {
id: newRole.id,
job_title_id: newRole.job_title_id,
jobtitle: newRole.jobtitle,
rate: newRole.rate,
members: [], // Initialize members array
},
]);
}
setAddingRow(false); setAddingRow(false);
}; };
@@ -102,18 +146,41 @@ const RatecardTable: React.FC = () => {
if (record.id) { if (record.id) {
dispatch(deleteProjectRateCardRoleById(record.id)); dispatch(deleteProjectRateCardRoleById(record.id));
} else { } else {
// Remove unsaved row
setRoles(roles.filter((_, idx) => idx !== index)); setRoles(roles.filter((_, idx) => idx !== index));
} }
}; };
// Handle member change
const handleMemberChange = async (memberId: string, rowIndex: number, record: JobRoleType) => {
if (!projectId || !record.id) return; // Ensure required IDs are present
try {
const resultAction = await dispatch(
assignMemberToRateCardRole({
project_id: projectId,
member_id: memberId,
project_rate_card_role_id: record.id,
})
);
if (assignMemberToRateCardRole.fulfilled.match(resultAction)) {
const updatedMembers = resultAction.payload; // Array of member IDs
setRoles((prev) =>
prev.map((role, idx) => {
if (idx !== rowIndex) return role;
return { ...role, members: updatedMembers?.members || [] };
})
);
}
} catch (error) {
console.error('Error assigning member:', error);
}
};
// Columns // Columns
const columns: TableProps<JobRoleType>['columns'] = [ const columns: TableProps<JobRoleType>['columns'] = [
{ {
title: t('jobTitleColumn'), title: t('jobTitleColumn'),
dataIndex: 'jobtitle', dataIndex: 'jobtitle',
render: (text: string, record: JobRoleType, index: number) => { render: (text: string, record: JobRoleType, index: number) => {
// Only show Select if addingRow and this is the last row (new row)
if (addingRow && index === roles.length) { if (addingRow && index === roles.length) {
return ( return (
<Select <Select
@@ -125,12 +192,12 @@ const RatecardTable: React.FC = () => {
onChange={handleSelectJobTitle} onChange={handleSelectJobTitle}
onBlur={() => setAddingRow(false)} onBlur={() => setAddingRow(false)}
filterOption={(input, option) => filterOption={(input, option) =>
(option?.children as string).toLowerCase().includes(input.toLowerCase()) (option?.children as string)?.toLowerCase().includes(input.toLowerCase())
} }
> >
{jobTitles {jobTitles
.filter(jt => !roles.some((role) => role.job_title_id === jt.id)) .filter((jt) => !roles.some((role) => role.job_title_id === jt.id))
.map(jt => ( .map((jt) => (
<Select.Option key={jt.id} value={jt.id!}> <Select.Option key={jt.id} value={jt.id!}>
{jt.name} {jt.name}
</Select.Option> </Select.Option>
@@ -138,19 +205,13 @@ const RatecardTable: React.FC = () => {
</Select> </Select>
); );
} }
return ( return <span>{text || record.name}</span>;
<span
style={{ cursor: 'pointer' }}
onClick={() => setEditingIndex(index)}
>
{text || record.name}
</span>
);
}, },
}, },
{ {
title: `${t('ratePerHourColumn')} (${currency})`, title: `${t('ratePerHourColumn')} (${currency})`,
dataIndex: 'rate', dataIndex: 'rate',
align: 'right',
render: (value: number, record: JobRoleType, index: number) => ( render: (value: number, record: JobRoleType, index: number) => (
<Input <Input
type="number" type="number"
@@ -161,6 +222,7 @@ const RatecardTable: React.FC = () => {
boxShadow: 'none', boxShadow: 'none',
padding: 0, padding: 0,
width: 80, width: 80,
textAlign: 'right',
}} }}
onChange={(e) => handleRateChange(e.target.value, index)} onChange={(e) => handleRateChange(e.target.value, index)}
/> />
@@ -169,34 +231,31 @@ const RatecardTable: React.FC = () => {
{ {
title: t('membersColumn'), title: t('membersColumn'),
dataIndex: 'members', dataIndex: 'members',
render: (members: string[] | null | undefined) => render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => (
members && members.length > 0 ? ( <div style={{ display: 'flex', alignItems: 'center', gap: 4, position: 'relative' }}>
<Avatar.Group> <Avatar.Group>
{members.map((member, i) => ( {memberscol?.map((memberId, i) => {
<CustomAvatar key={i} avatarName={member} size={26} /> const member = members.find((m) => m.id === memberId);
))} return member ? (
<CustomAvatar key={i} avatarName={member.name} size={26} />
) : null;
})}
</Avatar.Group> </Avatar.Group>
) : ( <div>
<Button <RateCardAssigneeSelector
shape="circle" projectId={projectId as string}
icon={ selectedMemberIds={memberscol || []}
<PlusOutlined onChange={(memberId) => handleMemberChange(memberId, index, record)}
style={{ memberlist={members}
fontSize: 12, />
width: 22, </div>
height: 22, </div>
display: 'flex', ),
alignItems: 'center',
justifyContent: 'center',
}}
/>
}
/>
),
}, },
{ {
title: t('actions'), title: t('actions'),
key: 'actions', key: 'actions',
align: 'center',
render: (_: any, record: JobRoleType, index: number) => ( render: (_: any, record: JobRoleType, index: number) => (
<Popconfirm <Popconfirm
title={t('deleteConfirm')} title={t('deleteConfirm')}
@@ -204,11 +263,7 @@ const RatecardTable: React.FC = () => {
okText={t('yes')} okText={t('yes')}
cancelText={t('no')} cancelText={t('no')}
> >
<Button <Button type="text" danger icon={<DeleteOutlined />} />
type="text"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm> </Popconfirm>
), ),
}, },
@@ -219,37 +274,33 @@ const RatecardTable: React.FC = () => {
dataSource={ dataSource={
addingRow addingRow
? [ ? [
...roles, ...roles,
{ {
job_title_id: '', job_title_id: '',
jobtitle: '', jobtitle: '',
rate: 0, rate: 0,
members: [], members: [],
}, },
] ]
: roles : roles
} }
columns={columns} columns={columns}
rowKey={(record, idx) => record.id || record.job_title_id || idx} rowKey={(record, idx) => record.id || record.job_title_id || String(idx)}
pagination={false} pagination={false}
loading={isLoading} loading={isLoading || isLoadingMembers}
footer={() => ( footer={() => (
<Flex gap={8}> <Flex gap={0}>
<Button <Button type="dashed" onClick={handleAddRole} style={{ width: 'fit-content' }}>
type="dashed"
onClick={handleAddRole}
style={{ width: 'fit-content' }}
>
{t('addRoleButton')} {t('addRoleButton')}
</Button> </Button>
<Button {/* <Button
type="primary" type="primary"
icon={<SaveOutlined />} icon={<SaveOutlined />}
onClick={handleSaveAll} onClick={handleSaveAll}
disabled={roles.length === 0} disabled={roles.length === 0}
> >
{t('saveButton') || 'Save'} {t('saveButton') || 'Save'}
</Button> </Button> */}
</Flex> </Flex>
)} )}
/> />