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

530 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;