530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
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;
|