pms-front-react/src/pages/projectBank/UserProjectPage.tsx

530 lines
18 KiB
TypeScript
Raw Normal View History

2026-03-17 07:18:07 +00:00
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { Button, DatePicker, Empty, Input, Modal, Spin, Table, Tree, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { UserOutlined } from '@ant-design/icons';
import zhCN from 'antd/es/date-picker/locale/zh_CN';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { useNavigate } from 'react-router-dom';
import PageBackButton from '@/components/PageBackButton';
import { getProjectExecutionInfo } from '@/api/projectExecution';
import { deptTreeSelect, getUserProfile, listUser } from '@/api/system/user';
import './user-project.css';
const { RangePicker } = DatePicker;
interface RoleRow {
roleName?: string;
}
interface UserRow {
_rowKey?: string;
userId?: string | number;
nickName?: string;
userName?: string;
phonenumber?: string;
dept?: { deptName?: string };
roles?: RoleRow[];
[key: string]: unknown;
}
interface ProjectExecutionRow {
projectId?: string | number;
projectName?: string;
allWorkTime?: string | number;
detailList?: Array<string | number | null | undefined>;
[key: string]: unknown;
}
interface DeptTreeNode {
key: string;
title: string;
rawId: string | number;
children?: DeptTreeNode[];
}
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const normalizeResponseData = (response: unknown) =>
isObject(response) && response.data !== undefined ? response.data : response;
const toNumber = (value: unknown, fallback = 0) => {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
};
const getDefaultRange = (): [Dayjs, Dayjs] => [dayjs().startOf('month'), dayjs().endOf('month')];
const normalizeExecutionRows = (payload: unknown): ProjectExecutionRow[] => {
const data = normalizeResponseData(payload);
const rows = Array.isArray(data)
? data
: isObject(data) && Array.isArray(data.rows)
? data.rows
: [];
return rows.filter(isObject) as ProjectExecutionRow[];
};
const normalizeUserRows = (payload: unknown): { rows: UserRow[]; total: number } => {
const data = normalizeResponseData(payload);
const rows = Array.isArray(data)
? data
: isObject(data) && Array.isArray(data.rows)
? data.rows
: [];
const total = isObject(data) ? toNumber(data.total, rows.length) : rows.length;
return {
rows: rows.filter(isObject).map((row, index) => ({
...(row as UserRow),
_rowKey: String((row as UserRow).userId ?? (row as UserRow).userName ?? `user_${index}`),
})) as UserRow[],
total,
};
};
const normalizeDeptTreeNodes = (payload: unknown): DeptTreeNode[] => {
const data = normalizeResponseData(payload);
if (!Array.isArray(data)) {
return [];
}
const mapNodes = (nodes: unknown[]): DeptTreeNode[] =>
nodes.flatMap((node, index) => {
if (!isObject(node)) {
return [];
}
const rawId = node.id ?? node.deptId ?? node.value ?? `dept_${index}`;
const normalizedId = typeof rawId === 'string' || typeof rawId === 'number' ? rawId : String(rawId);
return [
{
key: String(normalizedId),
rawId: normalizedId,
title: String(node.label ?? node.title ?? node.deptName ?? normalizedId),
children: Array.isArray(node.children) ? mapNodes(node.children) : undefined,
},
];
});
return mapNodes(data);
};
const collectDeptKeys = (nodes: DeptTreeNode[]): string[] =>
nodes.flatMap((node) => [node.key, ...(Array.isArray(node.children) ? collectDeptKeys(node.children) : [])]);
const getUserDisplayName = (user?: UserRow | null) => String(user?.nickName ?? user?.userName ?? '-');
const matchesUserKeyword = (user: UserRow, keyword: string) => {
const normalizedKeyword = keyword.trim();
if (!normalizedKeyword) {
return true;
}
return [user.nickName, user.userName, user.phonenumber].some((field) =>
String(field ?? '')
.toLowerCase()
.includes(normalizedKeyword.toLowerCase()),
);
};
const UserProjectPage = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [userLoading, setUserLoading] = useState(false);
const [deptLoading, setDeptLoading] = useState(false);
const [userModalOpen, setUserModalOpen] = useState(false);
const [userKeyword, setUserKeyword] = useState('');
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>(getDefaultRange());
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string | number>('');
const [selectedUserName, setSelectedUserName] = useState('');
const [pendingUserId, setPendingUserId] = useState<string | number>('');
const [executionData, setExecutionData] = useState<ProjectExecutionRow[]>([]);
const [userListData, setUserListData] = useState<UserRow[]>([]);
const [deptTree, setDeptTree] = useState<DeptTreeNode[]>([]);
const [expandedDeptKeys, setExpandedDeptKeys] = useState<string[]>([]);
const [selectedDeptId, setSelectedDeptId] = useState<string>('');
const [userPageNum, setUserPageNum] = useState(1);
const [userPageSize, setUserPageSize] = useState(10);
const [userTotal, setUserTotal] = useState(0);
const deferredUserKeyword = useDeferredValue(userKeyword);
useEffect(() => {
const fetchCurrentUser = async () => {
try {
const response = await getUserProfile();
const payload = normalizeResponseData(response);
const user = isObject(payload) && isObject(payload.user) ? payload.user : payload;
if (!isObject(user)) {
return;
}
const nextUser: UserRow = {
userId: user.userId as string | number | undefined,
nickName: user.nickName as string | undefined,
userName: user.userName as string | undefined,
};
setSelectedUser(nextUser);
setSelectedUserId(nextUser.userId ?? '');
setPendingUserId(nextUser.userId ?? '');
setSelectedUserName(getUserDisplayName(nextUser));
} catch (error) {
console.error('Failed to fetch current user profile for user project page:', error);
message.error('获取当前用户失败');
}
};
void fetchCurrentUser();
}, []);
useEffect(() => {
const fetchUserProject = async () => {
if (!selectedUserId || !dateRange[0] || !dateRange[1]) {
return;
}
setLoading(true);
try {
const response = await getProjectExecutionInfo({
startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`,
endDate: `${dateRange[1].format('YYYY-MM-DD')} 00:00:00`,
userId: selectedUserId,
});
setExecutionData(normalizeExecutionRows(response));
} catch (error) {
console.error('Failed to fetch user project table:', error);
message.error('获取人员项目表失败');
setExecutionData([]);
} finally {
setLoading(false);
}
};
void fetchUserProject();
}, [dateRange, selectedUserId]);
useEffect(() => {
if (!userModalOpen) {
return;
}
const fetchDeptTree = async () => {
setDeptLoading(true);
try {
const response = await deptTreeSelect();
const treeNodes = normalizeDeptTreeNodes(response);
setDeptTree(treeNodes);
setExpandedDeptKeys(collectDeptKeys(treeNodes));
} catch (error) {
console.error('Failed to fetch department tree for user project page:', error);
setDeptTree([]);
setExpandedDeptKeys([]);
} finally {
setDeptLoading(false);
}
};
void fetchDeptTree();
}, [userModalOpen]);
useEffect(() => {
if (!userModalOpen) {
return;
}
const fetchUserList = async () => {
setUserLoading(true);
try {
const normalizedKeyword = deferredUserKeyword.trim();
const response = await listUser({
pageNum: userPageNum,
pageSize: normalizedKeyword ? 1000 : userPageSize,
deptId: selectedDeptId || undefined,
});
const { rows, total } = normalizeUserRows(response);
const filteredRows = normalizedKeyword ? rows.filter((item) => matchesUserKeyword(item, normalizedKeyword)) : rows;
setUserListData(filteredRows);
setUserTotal(normalizedKeyword ? filteredRows.length : total);
} catch (error) {
console.error('Failed to fetch user list for user project page:', error);
message.error('获取用户列表失败');
setUserListData([]);
setUserTotal(0);
} finally {
setUserLoading(false);
}
};
void fetchUserList();
}, [deferredUserKeyword, selectedDeptId, userModalOpen, userPageNum, userPageSize]);
const pendingUser = useMemo(
() => userListData.find((item) => String(item.userId ?? '') === String(pendingUserId ?? '')) ?? selectedUser,
[pendingUserId, selectedUser, userListData],
);
const openUserModal = () => {
setPendingUserId(selectedUserId);
setUserPageNum(1);
setSelectedDeptId('');
setUserModalOpen(true);
};
const applySelectedUser = (user?: UserRow | null) => {
setSelectedUser(user ?? null);
setSelectedUserId(user?.userId ?? '');
setSelectedUserName(getUserDisplayName(user));
setPendingUserId(user?.userId ?? '');
setUserModalOpen(false);
};
const dynamicColumns = useMemo<ColumnsType<ProjectExecutionRow>>(() => {
const columns: ColumnsType<ProjectExecutionRow> = [
{
title: '项目',
dataIndex: 'projectName',
key: 'projectName',
fixed: 'left',
width: 180,
render: (value: unknown, row) => (
<Button
type="link"
className="user-project-link"
onClick={() => navigate(`/project/detail?id=${String(row.projectId ?? '')}`)}
>
{String(value ?? '-')}
</Button>
),
},
{
title: '统计工时\n',
dataIndex: 'allWorkTime',
key: 'allWorkTime',
fixed: 'left',
width: 140,
render: (value: unknown) => toNumber(value, 0),
},
];
let cursor = dateRange[0].startOf('day');
let index = 0;
while (!cursor.isAfter(dateRange[1], 'day')) {
const columnIndex = index;
const key = cursor.format('M/D');
const weekLabel = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][cursor.day()];
columns.push({
title: <span className="user-project-header">{`${weekLabel}\n${key}`}</span>,
key: `detail-${key}`,
dataIndex: 'detailList',
width: 100,
render: (value: unknown) => {
const detailList = Array.isArray(value) ? value : [];
return detailList[columnIndex] ?? '';
},
});
cursor = cursor.add(1, 'day');
index += 1;
}
return columns;
}, [dateRange, navigate]);
const summaryValues = useMemo(() => {
const dayCount = dateRange[1].startOf('day').diff(dateRange[0].startOf('day'), 'day') + 1;
const totalWorkTime = executionData.reduce((sum, row) => sum + toNumber(row.allWorkTime, 0), 0);
const dayTotals = Array.from({ length: Math.max(dayCount, 0) }, (_item, index) =>
Number(
executionData
.reduce((sum, row) => sum + toNumber(Array.isArray(row.detailList) ? row.detailList[index] : 0, 0), 0)
.toFixed(2),
),
);
return { totalWorkTime: Number(totalWorkTime.toFixed(2)), dayTotals };
}, [dateRange, executionData]);
return (
<div className="user-project-page">
<div className="user-project-back-row">
<PageBackButton text="返回看板统计" fallbackPath="/projectBank/projectProgress" />
</div>
<div className="user-project-shell">
<div className="user-project-header-row">
<h2 className="user-project-title"></h2>
<div className="user-project-toolbar">
<div className="user-project-user-box">
<span></span>
<Input
value={selectedUserName}
placeholder="请选择用户"
readOnly
suffix={<UserOutlined onClick={openUserModal} />}
onClick={openUserModal}
/>
</div>
<div className="user-project-range-box">
<span></span>
<RangePicker
value={dateRange}
onChange={(values) => {
if (!values || values.length !== 2 || !values[0] || !values[1]) {
return;
}
setDateRange([values[0], values[1]]);
}}
allowClear={false}
locale={zhCN}
format="YYYY-MM-DD"
/>
</div>
</div>
</div>
<Spin spinning={loading}>
<Table<ProjectExecutionRow>
className="user-project-table"
rowKey={(row) => String(row.projectId ?? row.projectName ?? '')}
columns={dynamicColumns}
dataSource={executionData}
pagination={false}
locale={{ emptyText: <Empty description="暂无项目数据" /> }}
scroll={{ x: Math.max(dynamicColumns.length * 100, 960), y: 600 }}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0}></Table.Summary.Cell>
<Table.Summary.Cell index={1}>{summaryValues.totalWorkTime}</Table.Summary.Cell>
{summaryValues.dayTotals.map((item, index) => (
<Table.Summary.Cell index={index + 2} key={`sum-${index}`}>
{item}
</Table.Summary.Cell>
))}
</Table.Summary.Row>
)}
/>
</Spin>
</div>
<Modal
title="选择人员"
open={userModalOpen}
onCancel={() => setUserModalOpen(false)}
width={980}
wrapClassName="user-select-dialog"
destroyOnHidden
footer={[
<Button key="cancel" onClick={() => setUserModalOpen(false)}>
</Button>,
<Button
key="ok"
type="primary"
disabled={!pendingUserId}
onClick={() => {
const hit =
userListData.find((item) => String(item.userId ?? '') === String(pendingUserId ?? '')) ?? pendingUser;
applySelectedUser(hit);
}}
>
</Button>,
]}
>
<div className="user-select-modal user-select-modal-layout">
<div className="user-select-dept-panel">
<Spin spinning={deptLoading}>
<Tree
blockNode
showLine
className="user-select-dept-tree"
expandedKeys={expandedDeptKeys}
selectedKeys={selectedDeptId ? [selectedDeptId] : []}
treeData={deptTree}
onExpand={(keys) => setExpandedDeptKeys(keys as string[])}
onSelect={(keys) => {
setSelectedDeptId(String(keys[0] ?? ''));
setUserPageNum(1);
}}
/>
</Spin>
</div>
<div className="user-select-table-panel">
<div className="user-select-query-bar">
<Input
placeholder="请输入姓名或手机号"
value={userKeyword}
onChange={(event) => {
const nextValue = event.target.value;
setUserPageNum(1);
setUserKeyword(nextValue);
}}
allowClear
size="large"
/>
</div>
<Table<UserRow>
rowKey={(row) => String(row._rowKey ?? row.userId ?? row.userName ?? '')}
loading={userLoading}
dataSource={userListData}
size="middle"
scroll={{ y: 420 }}
rowClassName={(record) =>
String(record.userId ?? '') === String(pendingUserId ?? '') ? 'user-select-row is-active' : 'user-select-row'
}
onRow={(record) => ({
onClick: () => {
setPendingUserId(record.userId ?? '');
},
onDoubleClick: () => {
applySelectedUser(record);
},
})}
locale={{ emptyText: <Empty description="暂无人员数据" /> }}
pagination={{
current: userPageNum,
pageSize: userPageSize,
total: userTotal,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setUserPageNum(page);
setUserPageSize(pageSize);
},
}}
columns={[
{
title: '序号',
key: 'index',
width: 90,
render: (_value, _row, index) => (userPageNum - 1) * userPageSize + index + 1,
},
{
title: '姓名',
dataIndex: 'nickName',
key: 'nickName',
width: 180,
render: (_value, row) => (
<div className="user-select-name-cell">
<strong>{getUserDisplayName(row)}</strong>
</div>
),
},
{
title: '部门',
dataIndex: ['dept', 'deptName'],
key: 'deptName',
width: 220,
render: (value: unknown) => String(value ?? '-'),
},
{
title: '角色',
dataIndex: 'roles',
key: 'roles',
render: (value: unknown) =>
Array.isArray(value) && value.length
? value.map((item) => String((item as RoleRow)?.roleName ?? '')).filter(Boolean).join('、')
: '-',
},
]}
/>
</div>
</div>
</Modal>
</div>
);
};
export default UserProjectPage;