diff --git a/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql index a4c17fe5..de15213f 100644 --- a/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql +++ b/worklenz-backend/database/migrations/20250519000000-project-finance-module-tables.sql @@ -45,4 +45,4 @@ ALTER TABLE projects ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL; ALTER TABLE finance_rate_cards - ADD COLUMN currency TEXT NOT NULL DEFAULT 'LKR'; + ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'; diff --git a/worklenz-backend/src/controllers/project-ratecard-controller.ts b/worklenz-backend/src/controllers/project-ratecard-controller.ts index 82d5b5ff..42c0edf4 100644 --- a/worklenz-backend/src/controllers/project-ratecard-controller.ts +++ b/worklenz-backend/src/controllers/project-ratecard-controller.ts @@ -6,9 +6,27 @@ import HandleExceptions from "../decorators/handle-exceptions"; import WorklenzControllerBase from "./worklenz-controller-base"; export default class ProjectRateCardController extends WorklenzControllerBase { + + // Insert a single role for a project + @HandleExceptions() + public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + 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 @HandleExceptions() - public static async insertMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async createMany(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { project_id, roles } = req.body; if (!Array.isArray(roles) || !project_id) { 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 @HandleExceptions() - public static async getFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { project_id } = req.params; 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 LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id WHERE fprr.project_id = $1 @@ -47,10 +72,17 @@ export default class ProjectRateCardController extends WorklenzControllerBase { // Get a single role by id @HandleExceptions() - public static async getFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { id } = req.params; 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 LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id WHERE fprr.id = $1; @@ -61,7 +93,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase { // Update a single role by id @HandleExceptions() - public static async updateFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { id } = req.params; const { job_title_id, rate } = req.body; const q = ` @@ -74,29 +106,120 @@ export default class ProjectRateCardController extends WorklenzControllerBase { 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 { + 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) @HandleExceptions() - public static async updateFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async updateByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { project_id, roles } = req.body; if (!Array.isArray(roles) || !project_id) { 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 no roles provided, do nothing and return empty array return res.status(200).send(new ServerResponse(true, [])); } + // Build upsert query for all roles const values = roles.map((role: any) => [ project_id, role.job_title_id, role.rate ]); const q = ` - INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) - VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")} - RETURNING *, - (SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle; + WITH upserted AS ( + INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate) + VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")} + 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 result = await db.query(q, flatValues); @@ -105,7 +228,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase { // Delete a single role by id @HandleExceptions() - public static async deleteFromId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { id } = req.params; const q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`; const result = await db.query(q, [id]); @@ -114,7 +237,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase { // Delete all roles for a project @HandleExceptions() - public static async deleteFromProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async deleteByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { project_id } = req.params; const q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`; const result = await db.query(q, [project_id]); diff --git a/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts index d056e368..09462c4d 100644 --- a/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts +++ b/worklenz-backend/src/routes/apis/project-ratecard-api-router.ts @@ -10,46 +10,60 @@ const projectRatecardApiRouter = express.Router(); projectRatecardApiRouter.post( "/", 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 projectRatecardApiRouter.get( "/project/:project_id", - safeControllerFunction(ProjectRateCardController.getFromProjectId) + safeControllerFunction(ProjectRateCardController.getByProjectId) ); // Get a single role by id projectRatecardApiRouter.get( "/:id", idParamValidator, - safeControllerFunction(ProjectRateCardController.getFromId) + safeControllerFunction(ProjectRateCardController.getById) ); // Update a single role by id projectRatecardApiRouter.put( "/:id", idParamValidator, - safeControllerFunction(ProjectRateCardController.updateFromId) + safeControllerFunction(ProjectRateCardController.updateById) ); // Update all roles for a project (delete then insert) projectRatecardApiRouter.put( "/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 projectRatecardApiRouter.delete( "/:id", idParamValidator, - safeControllerFunction(ProjectRateCardController.deleteFromId) + safeControllerFunction(ProjectRateCardController.deleteById) ); // Delete all roles for a project projectRatecardApiRouter.delete( "/project/:project_id", - safeControllerFunction(ProjectRateCardController.deleteFromProjectId) + safeControllerFunction(ProjectRateCardController.deleteByProjectId) ); export default projectRatecardApiRouter; diff --git a/worklenz-frontend/public/locales/en/project-view-finance.json b/worklenz-frontend/public/locales/en/project-view-finance.json index 19526d30..ee34c7e4 100644 --- a/worklenz-frontend/public/locales/en/project-view-finance.json +++ b/worklenz-frontend/public/locales/en/project-view-finance.json @@ -28,6 +28,10 @@ "jobTitleColumn": "Job Title", "ratePerHourColumn": "Rate per hour", "ratecardPluralText": "Rate Cards", - "labourHoursColumn": "Labour Hours" + "labourHoursColumn": "Labour Hours", + "actions": "Actions", + "selectJobTitle": "Select Job Title", + "ratecardsPluralText": "Rate Card Templates" + } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json index 374b69a8..e42e9883 100644 --- a/worklenz-frontend/public/locales/en/settings/ratecard-settings.json +++ b/worklenz-frontend/public/locales/en/settings/ratecard-settings.json @@ -19,6 +19,7 @@ "currency": "Currency", "actionsColumn": "Actions", "addAllButton": "Add All", - "removeAllButton": "Remove All" + "removeAllButton": "Remove All", + "selectJobTitle": "Select job title" } diff --git a/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts index a4ba6b5e..6546dbf9 100644 --- a/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts +++ b/worklenz-frontend/src/api/project-finance-ratecard/project-finance-rate-cards.api.service.ts @@ -1,7 +1,7 @@ import apiClient from '@api/api-client'; import { API_BASE_URL } from '@/shared/constants'; 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`; @@ -21,6 +21,14 @@ export const projectRateCardApiService = { const response = await apiClient.post>(rootUrl, { project_id, roles }); 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> { + const response = await apiClient.post>( + `${rootUrl}/create-project-rate-card-role`, + { project_id, job_title_id, rate } + ); + return response.data; + }, // Get all roles for a project async getFromProjectId(project_id: string): Promise> { @@ -46,6 +54,19 @@ export const projectRateCardApiService = { return response.data; }, + // Update project member rate card role + async updateMemberRateCardRole( + project_id: string, + member_id: string, + project_rate_card_role_id: string + ): Promise> { + const response = await apiClient.put>( + `${rootUrl}/project/${project_id}/members/${member_id}/rate-card-role`, + { project_rate_card_role_id } + ); + return response.data; + }, + // Delete a single role by id async deleteFromId(id: string): Promise> { const response = await apiClient.delete>(`${rootUrl}/${id}`); diff --git a/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx b/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx new file mode 100644 index 00000000..175ad48e --- /dev/null +++ b/worklenz-frontend/src/components/project-ratecard/ratecard-assignee-selector.tsx @@ -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(null); + const [searchQuery, setSearchQuery] = useState(''); + const [members, setMembers] = useState(memberlist); + + useEffect(() => { + setMembers(memberlist); + }, [memberlist]); + + const filteredMembers = members.filter((member) => + member.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const dropdownContent = ( + + setSearchQuery(e.currentTarget.value)} + placeholder="Search members" + /> + + {filteredMembers.length ? ( + filteredMembers.map((member) => ( + + onChange?.(member.id || '')} + /> + + {member.name} + + )) + ) : ( + + )} + + + ); + + return ( + dropdownContent} + onOpenChange={(open) => { + if (open) setTimeout(() => membersInputRef.current?.focus(), 0); + }} + > + - ) )} /> diff --git a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx index 923fc600..2a92b505 100644 --- a/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/finance/ratecard-tab/reatecard-table/ratecard-table.tsx @@ -7,12 +7,17 @@ import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; import { useTranslation } from 'react-i18next'; import { JobRoleType, IJobType, RatecardType } from '@/types/project/ratecard.types'; import { + assignMemberToRateCardRole, deleteProjectRateCardRoleById, fetchProjectRateCardRoles, + insertProjectRateCardRole, updateProjectRateCardRolesByProjectId, } from '@/features/finance/project-finance-slice'; import { useParams } from 'react-router-dom'; 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 dispatch = useAppDispatch(); @@ -27,8 +32,42 @@ const RatecardTable: React.FC = () => { // Local state for editing const [roles, setRoles] = useState(rolesRedux); const [addingRow, setAddingRow] = useState(false); - const [editingIndex, setEditingIndex] = useState(null); const [jobTitles, setJobTitles] = useState([]); + const [members, setMembers] = useState([]); + 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 useEffect(() => { @@ -40,7 +79,6 @@ const RatecardTable: React.FC = () => { // Sync local roles with redux roles useEffect(() => { - console.log('Roles Redux:', rolesRedux); setRoles(rolesRedux); }, [rolesRedux]); @@ -59,7 +97,6 @@ const RatecardTable: React.FC = () => { // Save all roles (bulk update) const handleSaveAll = () => { if (projectId) { - // Only send roles with job_title_id and rate const filteredRoles = roles .filter((r) => r.job_title_id && typeof r.rate !== 'undefined') .map((r) => ({ @@ -72,20 +109,27 @@ const RatecardTable: React.FC = () => { }; // Handle job title select for new row - const handleSelectJobTitle = (jobTitleId: string) => { + const handleSelectJobTitle = async (jobTitleId: string) => { const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId); - if (!jobTitle) return; - // Prevent duplicates + if (!jobTitle || !projectId) return; if (roles.some((r) => r.job_title_id === jobTitleId)) return; - setRoles([ - ...roles, - { - job_title_id: jobTitleId, - jobtitle: jobTitle.name || '', - rate: 0, - members: [], - }, - ]); + const resultAction = await dispatch( + insertProjectRateCardRole({ project_id: projectId, job_title_id: jobTitleId, rate: 0 }) + ); + + if (insertProjectRateCardRole.fulfilled.match(resultAction)) { + const newRole = resultAction.payload; + setRoles([ + ...roles, + { + id: newRole.id, + job_title_id: newRole.job_title_id, + jobtitle: newRole.jobtitle, + rate: newRole.rate, + members: [], // Initialize members array + }, + ]); + } setAddingRow(false); }; @@ -102,18 +146,41 @@ const RatecardTable: React.FC = () => { if (record.id) { dispatch(deleteProjectRateCardRoleById(record.id)); } else { - // Remove unsaved row 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 const columns: TableProps['columns'] = [ { title: t('jobTitleColumn'), dataIndex: 'jobtitle', 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) { return ( ); } - return ( - setEditingIndex(index)} - > - {text || record.name} - - ); + return {text || record.name}; }, }, { title: `${t('ratePerHourColumn')} (${currency})`, dataIndex: 'rate', + align: 'right', render: (value: number, record: JobRoleType, index: number) => ( { boxShadow: 'none', padding: 0, width: 80, + textAlign: 'right', }} onChange={(e) => handleRateChange(e.target.value, index)} /> @@ -169,34 +231,31 @@ const RatecardTable: React.FC = () => { { title: t('membersColumn'), dataIndex: 'members', - render: (members: string[] | null | undefined) => - members && members.length > 0 ? ( + render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => ( +
- {members.map((member, i) => ( - - ))} + {memberscol?.map((memberId, i) => { + const member = members.find((m) => m.id === memberId); + return member ? ( + + ) : null; + })} - ) : ( -
+ ), }, { title: t('actions'), key: 'actions', + align: 'center', render: (_: any, record: JobRoleType, index: number) => ( { okText={t('yes')} cancelText={t('no')} > - - + */} )} />