feat(ratecard): crud rename and ratecard-assignee-selector create

This commit is contained in:
shancds
2025-05-22 16:46:58 +05:30
parent a711d48c9c
commit 87bd1b8801
5 changed files with 209 additions and 42 deletions

View File

@@ -9,7 +9,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
// Insert a single role for a project
@HandleExceptions()
public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
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"));
@@ -26,7 +26,7 @@ public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Pr
}
// Insert multiple roles for a project
@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;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
@@ -50,10 +50,17 @@ public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Pr
// Get all roles for a project
@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 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
@@ -65,10 +72,17 @@ public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Pr
// Get a single role by id
@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 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;
@@ -79,7 +93,7 @@ public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Pr
// Update a single role by id
@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 { job_title_id, rate } = req.body;
const q = `
@@ -94,7 +108,7 @@ public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Pr
// Update all roles for a project (delete then insert)
@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;
if (!Array.isArray(roles) || !project_id) {
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
@@ -123,7 +137,7 @@ public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Pr
// Delete a single role by id
@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 q = `DELETE FROM finance_project_rate_card_roles WHERE id = $1 RETURNING *;`;
const result = await db.query(q, [id]);
@@ -132,7 +146,7 @@ public static async insertOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Pr
// Delete all roles for a project
@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 q = `DELETE FROM finance_project_rate_card_roles WHERE project_id = $1 RETURNING *;`;
const result = await db.query(q, [project_id]);

View File

@@ -10,52 +10,52 @@ 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.insertOne)
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)
);
// 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;

View File

@@ -0,0 +1,104 @@
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 { JobRoleType } from '@/types/project/ratecard.types';
import { IProjectMembersViewModel, 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 || []);
const [isLoading, setIsLoading] = useState(false);
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, overflow: 'auto' }}>
{filteredMembers.length ? (
filteredMembers.map(member => (
<List.Item
key={member.id}
style={{
display: 'flex',
gap: 8,
alignItems: 'flex-start', // changed from 'l' to 'flex-start' for left alignment
padding: '4px 8px',
border: 'none',
cursor: member.pending_invitation ? 'not-allowed' : 'pointer',
opacity: member.pending_invitation ? 0.5 : 1,
justifyContent: 'flex-start', // ensure content is aligned left
textAlign: 'left', // ensure text is aligned left
}}
onClick={() => !member.pending_invitation && onChange?.(member.id || '')}
>
<Checkbox
checked={selectedMemberIds.includes(member.id || '')}
disabled={member.pending_invitation}
onChange={() => onChange?.(member.id || '')}
/>
<div>
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
</div>
<span>{member.name}</span>
{/* <span style={{ fontSize: 12, color: '#888' }}>{member.email}</span> */}
</List.Item>
))
) : (
<Empty />
)}
</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,
isFinanceDrawerOpen: false,
isImportRatecardsDrawerOpen: false,
currency: 'LKR',
currency: 'USD',
isRatecardsLoading: false,
isFinanceDrawerloading: false,
drawerRatecard: null,

View File

@@ -14,8 +14,13 @@ import {
} 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();
const { t } = useTranslation('project-view-finance');
const { projectId } = useParams();
@@ -30,6 +35,40 @@ const RatecardTable: React.FC = () => {
const [addingRow, setAddingRow] = useState<boolean>(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [jobTitles, setJobTitles] = useState<RatecardType[]>([]);
const [members, setMembers] = useState<IProjectMemberViewModel[]>([]);
const [isLoadingMembers, setIsLoading] = useState(false);
const pagination = {
current: 1,
pageSize: 100,
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();
// eslint-disable-next-line
}, []);
// Fetch job titles for selection
useEffect(() => {
@@ -72,7 +111,7 @@ const RatecardTable: React.FC = () => {
};
// Handle job title select for new row
const handleSelectJobTitle = async(jobTitleId: string) => {
const handleSelectJobTitle = async (jobTitleId: string) => {
const jobTitle = jobTitles.find((jt) => jt.id === jobTitleId);
if (!jobTitle || !projectId) return;
// Prevent duplicates
@@ -180,30 +219,24 @@ 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) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, position: 'relative' }}>
<Avatar.Group>
{members.map((member, i) => (
<CustomAvatar key={i} avatarName={member} size={26} />
))}
{memberscol && memberscol.length > 0 &&
memberscol.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',
}}
/>
}
/>
),
<div>
<RateCardAssigneeSelector
projectId={projectId as string}
selectedMemberIds={memberscol || []}
onChange={memberId => handleMemberChange(memberId, index)}
memberlist={members}
/>
</div>
</div>
),
},
{
title: t('actions'),
@@ -225,6 +258,22 @@ const RatecardTable: React.FC = () => {
},
];
const handleMemberChange = (memberId: string, rowIndex: number) => {
setRoles(prev =>
prev.map((role, idx) => {
if (idx !== rowIndex) return role;
const members = Array.isArray(role.members) ? [...role.members] : [];
const memberIdx = members.indexOf(memberId);
if (memberIdx > -1) {
members.splice(memberIdx, 1); // remove if exists
} else {
members.push(memberId); // add if not exists
}
return { ...role, members };
})
);
};
return (
<Table
dataSource={