feat(project-ratecard-member): add members to the project ratecard func (FE)
This commit is contained in:
@@ -9,8 +9,7 @@ import Button from 'antd/es/button';
|
|||||||
import Empty from 'antd/es/empty';
|
import Empty from 'antd/es/empty';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import SingleAvatar from '../common/single-avatar/single-avatar';
|
import SingleAvatar from '../common/single-avatar/single-avatar';
|
||||||
import { JobRoleType } from '@/types/project/ratecard.types';
|
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||||
import { IProjectMembersViewModel, IProjectMemberViewModel } from '@/types/projectMember.types';
|
|
||||||
|
|
||||||
interface RateCardAssigneeSelectorProps {
|
interface RateCardAssigneeSelectorProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -23,13 +22,17 @@ const RateCardAssigneeSelector = ({
|
|||||||
projectId,
|
projectId,
|
||||||
onChange,
|
onChange,
|
||||||
selectedMemberIds = [],
|
selectedMemberIds = [],
|
||||||
memberlist,
|
memberlist = [],
|
||||||
}: RateCardAssigneeSelectorProps) => {
|
}: RateCardAssigneeSelectorProps) => {
|
||||||
const membersInputRef = useRef<InputRef>(null);
|
const membersInputRef = useRef<InputRef>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist || []);
|
const [members, setMembers] = useState<IProjectMemberViewModel[]>(memberlist);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const filteredMembers = members.filter(member =>
|
useEffect(() => {
|
||||||
|
setMembers(memberlist);
|
||||||
|
}, [memberlist]);
|
||||||
|
|
||||||
|
const filteredMembers = members.filter((member) =>
|
||||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -38,45 +41,40 @@ const RateCardAssigneeSelector = ({
|
|||||||
<Input
|
<Input
|
||||||
ref={membersInputRef}
|
ref={membersInputRef}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
placeholder="Search members"
|
placeholder="Search members"
|
||||||
/>
|
/>
|
||||||
<List style={{ padding: 0, overflow: 'auto' }}>
|
<List style={{ padding: 0, maxHeight: 200, overflow: 'auto' }}>
|
||||||
{filteredMembers.length ? (
|
{filteredMembers.length ? (
|
||||||
filteredMembers.map(member => (
|
filteredMembers.map((member) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
key={member.id}
|
key={member.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
alignItems: 'flex-start', // changed from 'l' to 'flex-start' for left alignment
|
alignItems: 'center',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: member.pending_invitation ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: member.pending_invitation ? 0.5 : 1,
|
opacity: member.pending_invitation ? 0.5 : 1,
|
||||||
justifyContent: 'flex-start', // ensure content is aligned left
|
justifyContent: 'flex-start',
|
||||||
textAlign: 'left', // ensure text is aligned left
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
onClick={() => !member.pending_invitation && onChange?.(member.id || '')}
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedMemberIds.includes(member.id || '')}
|
checked={selectedMemberIds.includes(member.id || '')}
|
||||||
disabled={member.pending_invitation}
|
disabled={member.pending_invitation}
|
||||||
onChange={() => onChange?.(member.id || '')}
|
onChange={() => onChange?.(member.id || '')}
|
||||||
/>
|
/>
|
||||||
<div>
|
<SingleAvatar
|
||||||
<SingleAvatar
|
avatarUrl={member.avatar_url}
|
||||||
avatarUrl={member.avatar_url}
|
name={member.name}
|
||||||
name={member.name}
|
email={member.email}
|
||||||
email={member.email}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span>{member.name}</span>
|
<span>{member.name}</span>
|
||||||
{/* <span style={{ fontSize: 12, color: '#888' }}>{member.email}</span> */}
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Empty />
|
<Empty description="No members found" />
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -87,7 +85,7 @@ const RateCardAssigneeSelector = ({
|
|||||||
overlayClassName="custom-dropdown"
|
overlayClassName="custom-dropdown"
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
dropdownRender={() => dropdownContent}
|
dropdownRender={() => dropdownContent}
|
||||||
onOpenChange={open => {
|
onOpenChange={(open) => {
|
||||||
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
|
if (open) setTimeout(() => membersInputRef.current?.focus(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -101,4 +99,4 @@ const RateCardAssigneeSelector = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RateCardAssigneeSelector;
|
export default RateCardAssigneeSelector;
|
||||||
@@ -18,9 +18,7 @@ import RateCardAssigneeSelector from '@/components/project-ratecard/ratecard-ass
|
|||||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||||
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
import { IProjectMemberViewModel } from '@/types/projectMember.types';
|
||||||
|
|
||||||
|
|
||||||
const RatecardTable: React.FC = () => {
|
const RatecardTable: React.FC = () => {
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('project-view-finance');
|
const { t } = useTranslation('project-view-finance');
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
@@ -33,16 +31,17 @@ 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 [members, setMembers] = useState<IProjectMemberViewModel[]>([]);
|
||||||
const [isLoadingMembers, setIsLoading] = useState(false);
|
const [isLoadingMembers, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const pagination = {
|
const pagination = {
|
||||||
current: 1,
|
current: 1,
|
||||||
pageSize: 100,
|
pageSize: 1000,
|
||||||
field: 'name',
|
field: 'name',
|
||||||
order: 'asc',
|
order: 'asc',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProjectMembers = async () => {
|
const getProjectMembers = async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -67,8 +66,7 @@ const RatecardTable: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProjectMembers();
|
getProjectMembers();
|
||||||
// eslint-disable-next-line
|
}, [projectId]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch job titles for selection
|
// Fetch job titles for selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,7 +79,7 @@ const RatecardTable: React.FC = () => {
|
|||||||
// Sync local roles with redux roles
|
// Sync local roles with redux roles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRoles(rolesRedux);
|
setRoles(rolesRedux);
|
||||||
}, [rolesRedux, dispatch]);
|
}, [rolesRedux]);
|
||||||
|
|
||||||
// Fetch roles on mount
|
// Fetch roles on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,7 +96,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) => ({
|
||||||
@@ -114,14 +111,11 @@ const RatecardTable: React.FC = () => {
|
|||||||
const handleSelectJobTitle = async (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 || !projectId) 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;
|
||||||
// Dispatch and wait for result
|
|
||||||
const resultAction = await dispatch(
|
const resultAction = await dispatch(
|
||||||
insertProjectRateCardRole({ project_id: projectId, job_title_id: jobTitleId, rate: 0 })
|
insertProjectRateCardRole({ project_id: projectId, job_title_id: jobTitleId, rate: 0 })
|
||||||
);
|
);
|
||||||
|
|
||||||
// If fulfilled, update local state with returned id
|
|
||||||
if (insertProjectRateCardRole.fulfilled.match(resultAction)) {
|
if (insertProjectRateCardRole.fulfilled.match(resultAction)) {
|
||||||
const newRole = resultAction.payload;
|
const newRole = resultAction.payload;
|
||||||
setRoles([
|
setRoles([
|
||||||
@@ -131,6 +125,7 @@ const RatecardTable: React.FC = () => {
|
|||||||
job_title_id: newRole.job_title_id,
|
job_title_id: newRole.job_title_id,
|
||||||
jobtitle: newRole.jobtitle,
|
jobtitle: newRole.jobtitle,
|
||||||
rate: newRole.rate,
|
rate: newRole.rate,
|
||||||
|
members: [], // Initialize members array
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -150,18 +145,40 @@ 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 = (memberId: string, rowIndex: number, record: JobRoleType) => {
|
||||||
|
if (!projectId && !memberId) return;
|
||||||
|
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 };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Log the required values
|
||||||
|
console.log({
|
||||||
|
project_id: projectId,
|
||||||
|
id: memberId,
|
||||||
|
project_rate_card_role_id: record.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 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
|
||||||
@@ -173,12 +190,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>
|
||||||
@@ -186,14 +203,7 @@ const RatecardTable: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <span>{text || record.name}</span>;
|
||||||
<span
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => setEditingIndex(index)}
|
|
||||||
>
|
|
||||||
{text || record.name}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -222,16 +232,18 @@ const RatecardTable: React.FC = () => {
|
|||||||
render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => (
|
render: (memberscol: string[] | null | undefined, record: JobRoleType, index: number) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, position: 'relative' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, position: 'relative' }}>
|
||||||
<Avatar.Group>
|
<Avatar.Group>
|
||||||
{memberscol && memberscol.length > 0 &&
|
{memberscol?.map((memberId, i) => {
|
||||||
memberscol.map((member, i) => (
|
const member = members.find((m) => m.id === memberId);
|
||||||
<CustomAvatar key={i} avatarName={member} size={26} />
|
return member ? (
|
||||||
))}
|
<CustomAvatar key={i} avatarName={member.name} size={26} />
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
</Avatar.Group>
|
</Avatar.Group>
|
||||||
<div>
|
<div>
|
||||||
<RateCardAssigneeSelector
|
<RateCardAssigneeSelector
|
||||||
projectId={projectId as string}
|
projectId={projectId as string}
|
||||||
selectedMemberIds={memberscol || []}
|
selectedMemberIds={memberscol || []}
|
||||||
onChange={memberId => handleMemberChange(memberId, index)}
|
onChange={(memberId) => handleMemberChange(memberId, index, record)}
|
||||||
memberlist={members}
|
memberlist={members}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,32 +260,12 @@ 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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Table
|
<Table
|
||||||
dataSource={
|
dataSource={
|
||||||
@@ -290,16 +282,12 @@ const RatecardTable: React.FC = () => {
|
|||||||
: 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={8}>
|
||||||
<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
|
||||||
|
|||||||
Reference in New Issue
Block a user