feat(ratecard): crud rename and ratecard-assignee-selector create
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -18,7 +18,7 @@ const initialState: financeState = {
|
||||
isRatecardDrawerOpen: false,
|
||||
isFinanceDrawerOpen: false,
|
||||
isImportRatecardsDrawerOpen: false,
|
||||
currency: 'LKR',
|
||||
currency: 'USD',
|
||||
isRatecardsLoading: false,
|
||||
isFinanceDrawerloading: false,
|
||||
drawerRatecard: null,
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user