Commit 3963f7ff authored by drigle's avatar drigle

fix: 优化项目视图、任务状态与导入日期处理

parent 75369fa1
......@@ -93,7 +93,7 @@ Layout route/path change + 60s timer + notification-state event
- HeroUI Pro 当前没有 Gantt 组件;甘特图继续复用项目既有日期窗口、列计算和任务区间算法,不误用垂直事件 `Timeline`。
- 可见结构使用 HeroUI 原生组合:L1 `Card` 承载分析模块,L2 `Surface secondary` 承载时间轴,L3 `Surface tertiary` 承载刻度和任务行。
- 实际进度与时间进度使用 `ProgressBar` compound API;结果状态使用语义 `Chip`,空范围使用 Pro `EmptyState`。
- 实际进度与时间进度使用 `ProgressBar` compound API;项目业务状态与日期派生结果在甘特图展示层合并为唯一语义状态标签,空范围使用 Pro `EmptyState`。
- 共享 CSS 只控制网格、宽度、间距、对齐与横向滚动;组件颜色、圆角、描边和交互状态由 HeroUI variant 与鑫元共享 Tokens 负责。
- 图表颜色由 `tokens.css` 的共享 `--rc-chart-*` 序列和 `XinyuanEChart` 适配器统一提供,优先使用品牌红深浅、暖色、中性和灰蓝层级;业务页面不得添加绿色、荧光色或页面私有图表色。
......@@ -102,6 +102,7 @@ Layout route/path change + 60s timer + notification-state event
- 个人日历直接使用 HeroUI Pro `Agenda` 的 month compound API:`Header`、`Navigation`、`Body`、`MonthGrid`、`MonthRow`、`MonthCell`、`MonthSpanningEvent` 与 `MonthEvent`。
- Agenda Header 的月份导航由官方 `NavButton`、受控 `heading` 和 `TodayButton` 组成,顺序固定为上月、当前年月、下月、今天;Header 水平与垂直 inset 均与甘特图 Card Header 对齐。
- 页面只把 `pm_project_task` 的计划开始/结束日期转换为只读全天事件;Agenda 持有月份矩阵、跨周分段、非本月日期、今日状态和键盘交互。
- 页面按 Record `_id` 对日历输入去重,并在月视图的第一个可见事件片段展示完整标题;同一事件的后续跨周片段仅展示“持续中”,同时保留可访问名称和点击编辑能力。
- `onEventSelect` 继续映射到既有项目编辑回调;不传入创建、移动、缩放或删除回调,日历不会修改业务数据。
- 外层业务模块使用批准的 HeroUI Card contract;窄视口只在 Agenda Body 内横向滚动,不允许形成页面级横向溢出。
......
......@@ -62,7 +62,7 @@
- 图表:ECharts + `echarts-for-react` 通过共享 `XinyuanEChart` 适配器渲染;应用扩展 `--rc-chart-*` 使用 `#DA241A` 品牌红及低饱和红、暖色、中性、灰蓝层级,禁止绿色系、荧光色和彩虹式相邻对撞。适配器读取扩展色板后再向 ECharts 注入具体颜色;Tooltip、坐标轴和饼图间隙由适配器统一处理。
- 模块头部只保留功能标题;图表、甘特图、日历和清单不显示重复性副标题或日期说明,必要的范围和解释由控件、坐标轴或弹窗上下文承载。
- 字段标题旁的持久性规则说明使用小型 HeroUI Tooltip 信息图标承载;以项目状态为例,工作流说明不再占用字段下方垂直空间。
- 个人日历:HeroUI Pro Agenda 月视图;跨日期项目使用 MonthSpanningEvent,单日项目使用 MonthEvent,导航和日期状态使用 Agenda 官方 compound API。
- 个人日历:HeroUI Pro Agenda 月视图;跨日期项目使用 MonthSpanningEvent,单日项目使用 MonthEvent,导航和日期状态使用 Agenda 官方 compound API;同一项目在当月首次可见片段展示完整标题,后续跨周片段统一展示“持续中”,避免重复标题干扰日期阅读。
- 个人日历 Header 导航按“上月、当前年月、下月、今天”顺序排列,当前年月使用 Agenda 的 `heading` 受控显示,不把“本月”按钮置于箭头之间;Agenda Header 移除额外水平和垂直 inset,与甘特图标题共享 Card 内容边缘。
- 消息通知:Navbar 直接使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`;36px Bell 位于右侧动作区。独立通知页使用 HeroUI Tabs、SearchField、Card、Pagination 和 HeroUI Pro EmptyState,侧栏不增加通知入口。
......
......@@ -2,7 +2,8 @@
"name": "project_manager_xy",
"version": "1.0.4",
"build": "abc",
"description": "merge master",
"description": "部门任务管理",
"date": "2025-07-14 19:27:20",
"author": "yang"
"author": "yang",
"spa": true
}
......@@ -90,6 +90,32 @@ function formatUserLabel(user) {
return user?.displayName || user?.username || user?.id || '-';
}
function formatUserListLabel(users = []) {
return users.filter(Boolean).map(formatUserLabel).join('、') || '-';
}
function UserListCell({ users = [] }) {
const normalizedUsers = users.filter(Boolean);
const displayNames = formatUserListLabel(normalizedUsers);
const usernames = normalizedUsers
.map((user) => user?.username || user?.id)
.filter(Boolean)
.join('、');
return (
<span className="rc-data-grid-text-pair">
<span className="rc-data-grid-primary block whitespace-normal break-words">
{displayNames}
</span>
{usernames ? (
<span className="rc-data-grid-secondary block whitespace-normal break-words">
{usernames}
</span>
) : null}
</span>
);
}
function UserSearchSelect(props) {
return <XinyuanUserAutocomplete {...props} />;
}
......@@ -103,6 +129,7 @@ function OptionAutocomplete({
onChange,
options,
placeholder = '请选择',
isDisabled = false,
isRequired = false,
}) {
const normalizedOptions = (options || []).map((option) => ({
......@@ -111,6 +138,7 @@ function OptionAutocomplete({
}));
return (
<XinyuanAutocomplete
isDisabled={isDisabled}
isRequired={isRequired}
options={normalizedOptions}
placeholder={placeholder}
......@@ -147,17 +175,33 @@ function normalizeImportText(value) {
return String(value ?? '').trim();
}
function normalizeCalendarDate(yearValue, monthValue, dayValue) {
const year = Number(yearValue);
const month = Number(monthValue);
const day = Number(dayValue);
const date = new Date(Date.UTC(year, month - 1, day));
if (
!Number.isInteger(year) ||
!Number.isInteger(month) ||
!Number.isInteger(day) ||
year < 1000 ||
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return '';
}
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function parseLooseDateInput(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const directDate = formatDateInput(normalizedValue);
if (directDate) {
return directDate;
}
const excelDate = Number(normalizedValue);
if (Number.isFinite(excelDate) && excelDate > 59) {
const parsedExcelDate = new Date(Math.round((excelDate - 25569) * 86400 * 1000));
......@@ -177,21 +221,27 @@ function parseLooseDateInput(value) {
const fullDateMatch = sanitizedValue.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (fullDateMatch) {
const [, year, month, day] = fullDateMatch;
return formatDateInput(
`${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
);
return normalizeCalendarDate(year, month, day);
}
const excelFormattedDateMatch = sanitizedValue.match(/^(\d{1,2})-(\d{1,2})-(\d{2}|\d{4})$/);
if (excelFormattedDateMatch) {
const [, month, day, yearValue] = excelFormattedDateMatch;
const shortYear = Number(yearValue);
const year = yearValue.length === 2
? shortYear + (shortYear >= 50 ? 1900 : 2000)
: shortYear;
return normalizeCalendarDate(year, month, day);
}
const monthDayMatch = normalizedValue.match(/^(\d{1,2})月(\d{1,2})日?$/);
if (monthDayMatch) {
const currentYear = new Date().getFullYear();
const [, month, day] = monthDayMatch;
return formatDateInput(
`${currentYear}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
);
return normalizeCalendarDate(currentYear, month, day);
}
return '';
return formatDateInput(normalizedValue);
}
function parsePercentInput(value) {
......@@ -654,6 +704,7 @@ function getGanttColumnMinWidth(viewType) {
function buildTaskFormValues(task) {
const metadata = task?.metadata || {};
const actualFinishDate = formatDateInput(metadata.actual_finish_date);
return {
project_name: metadata.project_name || '',
big_category: metadata.big_category || '',
......@@ -674,12 +725,12 @@ function buildTaskFormValues(task) {
planned_start_date: formatDateInput(metadata.planned_start_date),
planned_end_date: formatDateInput(metadata.planned_end_date),
actual_start_date: formatDateInput(metadata.actual_start_date),
actual_finish_date: formatDateInput(metadata.actual_finish_date),
actual_finish_date: actualFinishDate,
description: metadata.description || '',
remark: metadata.remark || '',
priority: metadata.priority || '',
is_annual_audit_plan: Boolean(metadata.is_annual_audit_plan),
project_status: metadata.project_status || 'draft',
project_status: actualFinishDate ? 'completed' : metadata.project_status || 'draft',
completion_percent: metadata.completion_percent ?? 0,
delay_reason: metadata.delay_reason || '',
weekly_updates: normalizeWeeklyUpdatesValue(metadata.weekly_updates),
......@@ -732,35 +783,81 @@ function FilterField({ label, children }) {
</div>
);
}
function getTaskTone(task) {
function getGanttDisplayStatus(task) {
if (task.projectStatus === 'cancelled') {
return { key: 'cancelled', label: '已取消' };
}
if (task.resultStatus === 'completed_early') {
return { key: 'completed_early', label: '提前完成' };
}
if (task.resultStatus === 'completed_on_time') {
return { key: 'completed_on_time', label: '准时完成' };
}
if (task.resultStatus === 'completed_overdue') {
return { key: 'completed_overdue', label: '超时完成' };
}
if (task.projectStatus === 'blocked') {
return {
key: 'blocked',
label: task.isOvertime ? `受阻(超时 ${task.overtimeDays} 天)` : '受阻',
};
}
if (task.resultStatus === 'overdue') {
return {
key: 'overdue',
label: task.isOvertime ? `已超时(${task.overtimeDays} 天)` : '已超时',
};
}
if (task.resultStatus === 'not_started') {
return { key: 'not_started', label: '未开始' };
}
if (task.projectStatus === 'in_progress') {
return { key: 'in_progress', label: '进行中' };
}
if (task.projectStatus === 'completed') {
return { key: 'completed', label: '已完成' };
}
return {
key: task.projectStatus || 'draft',
label: task.projectStatusLabel || '草稿',
};
}
function getTaskTone(displayStatus) {
if (
displayStatus.key === 'completed_early' ||
displayStatus.key === 'completed_on_time' ||
displayStatus.key === 'completed'
) {
return {
surface: 'bg-emerald-50 border-emerald-200',
accent: 'bg-emerald-500',
text: 'text-emerald-700',
};
}
if (task.resultStatus === 'completed_overdue' || task.resultStatus === 'overdue') {
if (displayStatus.key === 'completed_overdue' || displayStatus.key === 'overdue') {
return {
surface: 'bg-rose-50 border-rose-200',
accent: 'bg-rose-500',
text: 'text-rose-700',
};
}
if (task.projectStatus === 'blocked') {
if (displayStatus.key === 'blocked') {
return {
surface: 'bg-amber-50 border-amber-200',
accent: 'bg-amber-500',
text: 'text-amber-700',
};
}
if (task.projectStatus === 'completed') {
return {
surface: 'bg-rose-50 border-rose-200',
accent: 'bg-rose-500',
text: 'text-rose-700',
};
}
return {
surface: 'bg-zinc-50 border-zinc-200',
accent: 'bg-[var(--pm-brand)]',
......@@ -770,11 +867,12 @@ function getTaskTone(task) {
function GanttLegend() {
const items = [
{ label: '正常进行', className: 'bg-zinc-400' },
{ label: '提前完成', className: 'bg-emerald-500' },
{ label: '已超时', className: 'bg-rose-500' },
{ label: '已完成', className: 'bg-rose-500' },
{ label: '待开始', className: 'bg-zinc-400' },
{ label: '进行中', className: 'bg-[var(--pm-brand)]' },
{ label: '完成', className: 'bg-emerald-500' },
{ label: '超时', className: 'bg-rose-500' },
{ label: '受阻', className: 'bg-amber-500' },
{ label: '已取消', className: 'bg-zinc-400' },
];
return (
......@@ -842,7 +940,8 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
</div>
))}
{visibleTasks.length > 0 ? visibleTasks.map((task) => {
const tone = getTaskTone(task);
const displayStatus = getGanttDisplayStatus(task);
const tone = getTaskTone(displayStatus);
const taskId = getTaskRecordId(task);
const visibleStartIndex = timelineMeta.columns.findIndex((column) => column.endKey >= task.plannedStartDate);
const visibleEndIndex = [...timelineMeta.columns].reverse().findIndex((column) => column.startKey <= task.plannedEndDate);
......@@ -855,16 +954,25 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
<button type="button" onClick={() => onSelectTask(taskId)} className={clsx('sticky left-0 z-[5] flex h-full min-h-[104px] flex-col items-start justify-center border-b border-r border-zinc-200 bg-white px-4 py-2 text-left transition hover:bg-zinc-50', selectedTaskId === taskId ? 'bg-rose-50' : '')}>
<div className="w-full">
<div className="truncate text-sm font-semibold text-zinc-900">{task.projectName}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-4 text-zinc-500">
<span className="truncate">{task.bigCategory || task.projectCategory || '未分类'}</span>
<span className="truncate">负责人 {formatUserLabel(task.projectOwnerUser)}</span>
<span className={clsx('font-medium', tone.text)}>{task.projectStatusLabel} / {task.resultStatusLabel}</span>
<div className="mt-1 grid gap-0.5 text-[11px] leading-4 text-zinc-500">
<span className="whitespace-normal break-words">
{task.bigCategory || task.projectCategory || '未分类'}
</span>
<span className="whitespace-normal break-words">
负责人 {formatUserListLabel(task.projectOwnerUsers)}
</span>
<span className="whitespace-normal break-words">
协作人 {formatUserListLabel(task.projectCollaboratorUsers)}
</span>
<span className={clsx('font-medium', tone.text)}>
{displayStatus.label}
</span>
</div>
</div>
</button>
<div className="relative h-[104px] border-b border-zinc-200" style={{ gridColumn: `2 / span ${timelineMeta.columns.length}` }}>
<div className="relative h-full min-h-[104px] border-b border-zinc-200" style={{ gridColumn: `2 / span ${timelineMeta.columns.length}` }}>
<div className="absolute inset-0 grid" style={{ gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))` }}>
{timelineMeta.columns.map((column) => <div key={`${taskId}-${column.key}`} className={clsx('h-[104px] border-r border-zinc-200', column.isToday ? 'bg-rose-50/60' : 'bg-white')} />)}
{timelineMeta.columns.map((column) => <div key={`${taskId}-${column.key}`} className={clsx('h-full min-h-[104px] border-r border-zinc-200', column.isToday ? 'bg-rose-50/60' : 'bg-white')} />)}
</div>
{hasVisibleBar ? <div className="absolute inset-0 grid items-center" style={{ gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`, gridTemplateRows: '1fr' }}>
<div className="flex h-full items-center" style={{ gridColumn: `${startIndex + 1} / span ${span}` }}>
......@@ -873,7 +981,7 @@ function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask })
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-semibold text-zinc-900">{task.projectName}</div>
<span className={clsx('ml-3 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', tone.surface, tone.text)}>{task.resultStatusLabel}</span>
<span className={clsx('ml-3 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', tone.surface, tone.text)}>{displayStatus.label}</span>
</div>
<div className="mt-1 truncate text-[11px] leading-4 text-zinc-500">时间 {formatPercent(task.plannedProgressPercent)} · 实际 {formatPercent(task.progressPercent)} · {task.plannedStartDate} ~ {task.plannedEndDate} · 周进度 {task.weeklyUpdateCount} 条{task.latestWeeklySummary ? ` · ${task.latestWeeklySummary}` : ''}</div>
</div>
......@@ -898,10 +1006,24 @@ function ProjectCalendarBoard({
onOpenTask,
}) {
const personalTasks = useMemo(
() =>
currentUserId
() => {
const matchingTasks = currentUserId
? tasks.filter((task) => task.relatedUserIds.includes(currentUserId))
: tasks,
: tasks;
const seenTaskIds = new Set();
return matchingTasks.filter((task) => {
const taskId = String(getTaskRecordId(task) || '');
if (!taskId) {
return true;
}
if (seenTaskIds.has(taskId)) {
return false;
}
seenTaskIds.add(taskId);
return true;
});
},
[currentUserId, tasks],
);
const calendarEvents = useMemo(
......@@ -956,6 +1078,37 @@ function ProjectCalendarBoard({
}
},
});
const monthRows = useMemo(() => {
const labeledEventIds = new Set();
const markEventFragment = (event) => {
const eventId = String(event.id);
const isContinuation = labeledEventIds.has(eventId);
labeledEventIds.add(eventId);
return { event, isContinuation };
};
return agenda.visibleWeeks.map((week) => {
const rowLayout = agenda.getMonthRowLayout(week);
const spanningItems = rowLayout.items.map((item) => ({
...item,
...markEventFragment(item.event),
}));
const cells = week.map((date) => ({
date,
events: agenda
.getPerCellEvents(date, week)
.map((event) => markEventFragment(event)),
}));
return {
key: week[0].toString(),
rowCount: rowLayout.rowCount,
spanningItems,
cells,
};
});
}, [agenda.getMonthRowLayout, agenda.getPerCellEvents, agenda.visibleWeeks]);
return (
<Card className="rc-card-surface rc-dashboard-card">
......@@ -981,43 +1134,47 @@ function ProjectCalendarBoard({
</Agenda.Header>
<Agenda.Body>
<Agenda.MonthGrid className="min-w-[720px]">
{agenda.visibleWeeks.map((week) => {
const rowLayout = agenda.getMonthRowLayout(week);
return (
<Agenda.MonthRow
key={week[0].toString()}
spanningRowCount={rowLayout.rowCount}
>
{rowLayout.items.map((item) => (
<Agenda.MonthSpanningEvent
key={item.event.id}
aria-label={item.event.title}
event={item.event}
colStart={item.colStart}
colSpan={item.colSpan}
row={item.row}
>
{item.event.title}
</Agenda.MonthSpanningEvent>
))}
{week.map((date) => (
<Agenda.MonthCell
key={date.toString()}
date={date}
maxEvents={2}
moreLabel={(count) => `另有 ${count} 项`}
spanningRowCount={rowLayout.rowCount}
>
{agenda.getPerCellEvents(date, week).map((event) => (
<Agenda.MonthEvent key={event.id} event={event}>
{event.title}
</Agenda.MonthEvent>
))}
</Agenda.MonthCell>
))}
</Agenda.MonthRow>
);
})}
{monthRows.map((monthRow) => (
<Agenda.MonthRow
key={monthRow.key}
spanningRowCount={monthRow.rowCount}
>
{monthRow.spanningItems.map((item) => (
<Agenda.MonthSpanningEvent
key={`${monthRow.key}-${item.event.id}`}
aria-label={
item.isContinuation
? `${item.event.title},排期延续`
: item.event.title
}
event={item.event}
colStart={item.colStart}
colSpan={item.colSpan}
row={item.row}
>
{item.isContinuation ? '持续中' : item.event.title}
</Agenda.MonthSpanningEvent>
))}
{monthRow.cells.map(({ date, events }) => (
<Agenda.MonthCell
key={date.toString()}
date={date}
maxEvents={2}
moreLabel={(count) => `另有 ${count} 项`}
spanningRowCount={monthRow.rowCount}
>
{events.map(({ event, isContinuation }) => (
<Agenda.MonthEvent
key={`${date.toString()}-${event.id}`}
event={event}
>
{isContinuation ? '持续中' : event.title}
</Agenda.MonthEvent>
))}
</Agenda.MonthCell>
))}
</Agenda.MonthRow>
))}
</Agenda.MonthGrid>
</Agenda.Body>
</Agenda>
......@@ -1061,15 +1218,14 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
{
id: 'owner',
header: '负责人',
minWidth: 160,
cell: (task) => (
<span className="rc-data-grid-text-pair">
<span className="rc-data-grid-primary block truncate">{formatUserLabel(task.projectOwnerUser)}</span>
<span className="rc-data-grid-secondary block truncate">
{task.projectOwnerUser?.username || task.projectOwnerUser?.id || ''}
</span>
</span>
),
minWidth: 180,
cell: (task) => <UserListCell users={task.projectOwnerUsers} />,
},
{
id: 'collaborator',
header: '协作人',
minWidth: 180,
cell: (task) => <UserListCell users={task.projectCollaboratorUsers} />,
},
{
id: 'status',
......@@ -1152,7 +1308,7 @@ function ProjectListPanel({ tasks, onSelectTask, onEditTask, onPreviewTask }) {
allowsColumnResize
aria-label="项目列表"
columns={columns}
contentClassName="min-w-[1320px]"
contentClassName="min-w-[1520px]"
data={pageRows}
getRowId={getTaskRecordId}
renderEmptyState={() => (
......@@ -1360,7 +1516,7 @@ function WeeklyUpdateEditorDialog({
}
const IMPORT_PREVIEW_COLUMNS = [
{ id: '__rowIndex', header: '#', accessorKey: '__rowIndex', width: 72, isRowHeader: true },
{ id: '__rowIndex', header: '序号', accessorKey: '__rowIndex', width: 72, isRowHeader: true },
{
id: 'project_name',
header: '项目名称',
......@@ -1655,10 +1811,18 @@ function ProjectTaskDialog({
}, [open, task, viewOnly]);
const updateField = (field, value) => {
setFormValues((current) => ({
...current,
[field]: value,
}));
setFormValues((current) => {
const nextValues = {
...current,
[field]: value,
};
if (formatDateInput(nextValues.actual_finish_date)) {
nextValues.project_status = 'completed';
}
return nextValues;
});
};
const handleStartProject = () => {
......@@ -2030,13 +2194,16 @@ function ProjectTaskDialog({
>
<Info size={14} />
</Tooltip.Trigger>
<Tooltip.Content>工作流:草稿 {'->'} 进行中 {'->'} 已完成</Tooltip.Content>
<Tooltip.Content>
工作流:草稿 {'->'} 进行中 {'->'} 已完成;填写实际完成日期后自动锁定为已完成
</Tooltip.Content>
</Tooltip>
</span>
<OptionAutocomplete
value={formValues.project_status}
onChange={(value) => updateField('project_status', value)}
options={projectStatusOptions}
isDisabled={Boolean(formatDateInput(formValues.actual_finish_date))}
isRequired
/>
</label>
......@@ -2311,39 +2478,9 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}
};
useEffect(() => {
const hasChanged = Object.keys(filters).some(
(key) => filters[key] !== queryFilters[key],
);
if (!hasChanged) {
return undefined;
}
const delay = filters.keyword !== queryFilters.keyword ? 250 : 0;
const timeoutId = window.setTimeout(() => {
setQueryFilters({ ...filters });
}, delay);
return () => window.clearTimeout(timeoutId);
}, [
filters.keyword,
filters.status,
filters.ownerId,
filters.collaboratorId,
queryFilters.keyword,
queryFilters.status,
queryFilters.ownerId,
queryFilters.collaboratorId,
]);
useEffect(() => {
loadWorkspace(queryFilters);
}, [
queryFilters.keyword,
queryFilters.status,
queryFilters.ownerId,
queryFilters.collaboratorId,
]);
}, [queryFilters]);
useEffect(() => {
let cancelled = false;
......@@ -2443,7 +2580,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
try {
const rows = await readProjectTaskImportRows(file);
const preview = await Promise.all(
rows.map((row, index) => buildImportPreviewRow(row, index + 2)),
rows.map((row, index) => buildImportPreviewRow(row, index + 1)),
);
setImportPreviewRows(preview);
setImportDialogOpen(true);
......@@ -2565,7 +2702,6 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const handleOpenTaskFromCalendar = (task) => {
setSelectedTaskId(getTaskRecordId(task));
setActiveTab('gantt');
openEditDialog(task);
};
......@@ -2651,7 +2787,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
</FilterField>
</div>
<div className="flex flex-wrap items-end xl:flex-nowrap">
<div className="flex flex-wrap items-end gap-2 xl:flex-nowrap">
<Button
isPending={loading}
variant="primary"
onPress={() => setQueryFilters({ ...filters })}
>
查询
</Button>
<Disclosure.Trigger className="button button--sm button--ghost">
<span>{filtersExpanded ? '收起' : '展开'}</span>
<Disclosure.Indicator><ChevronDownIcon size={14} /></Disclosure.Indicator>
......
......@@ -36,7 +36,6 @@ export async function loadNotificationPage({
if (normalizedKeyword) {
filter.content = {
$regex: escapeRegExp(normalizedKeyword),
$options: 'i',
};
}
......
......@@ -20,7 +20,8 @@
- 左侧菜单:`资源预定`
- `资源预定` 顶部使用项目甘特图、个人日历图、项目清单三个 Tabs;移动端 Tabs 可横向滚动,批量导入和新建项目操作位于独立工具行
- 筛选区默认展示项目搜索、项目状态、项目负责人;项目协作人位于可展开的筛选区域。筛选不提供刷新或查询按钮,选择类条件变化后立即查询,关键词停止输入 250ms 后自动查询;连续请求只允许最后一次结果写回页面。请求加载期间筛选 Surface 保持稳定,不显示常驻加载提示。
- 筛选区默认展示项目搜索、项目状态、项目负责人和“查询”按钮;项目协作人位于可展开的筛选区域。首次进入页面自动加载项目;输入、选择或清除筛选条件仅更新待查询条件,点击“查询”后才统一应用到甘特图、个人日历图、项目清单及统计卡片,相同条件可再次点击查询。保存、删除或导入后的刷新沿用上次已提交的条件,不应用尚未点击查询的修改。查询期间按钮显示加载状态,筛选 Surface 保持稳定;连续请求只允许最后一次结果写回页面。查询失败显示错误提示,可再次点击“查询”重试;无匹配数据时展示空状态。
- 项目关键词通过项目名称、大类、项目、任务来源和任务性质的正则条件查询,使用服务端默认匹配规则(不指定忽略大小写选项)。
- 枚举和用户选择字段统一使用 HeroUI Autocomplete:选择后触发器立即回显当前标签,再次打开时保留选项勾选;非必填且可编辑的已选字段显示清除按钮,清除后按原字段协议写回空字符串或空 ID 数组
- `项目清单` 使用原生行选择;选择一条记录后在视口下方显示操作栏,可编辑所选项目或清空选择
......@@ -70,7 +71,7 @@
1. 成员登录系统
2. 日历图查询当前用户包含在 `project_owner` 或 `project_collaborator` 数组中的项目记录
3. 甘特图按项目展示所有任务;个人日历图仅展示当前用户关联项目
4. 项目清单默认每页 10 条分页,可在分页栏选择 10、20 或 50 条/页;行操作区提供“预览项目”和“编辑项目”两个图标入口,选择后操作栏继续进入原项目编辑流程
4. 项目清单默认每页 10 条分页,可在分页栏选择 10、20 或 50 条/页;负责人与协作人分别按多值用户数组完整展示显示名和用户名,不仅取第一人;行操作区提供“预览项目”和“编辑项目”两个图标入口,选择后操作栏继续进入原项目编辑流程
5. 点击“预览项目”打开 4xl 详情弹窗,以无框标签/值布局展示项目基础信息、状态与进度、计划/实际日期及周进度;查看态底部“编辑”按钮沿用原项目编辑流程,右上角关闭图标退出预览,关闭或切换编辑不会触发额外查询请求
6. 可在甘特图、日历图或项目清单中查看项目名称、计划时间、共享进度、状态
......@@ -81,7 +82,7 @@
3. 新建弹窗在项目尚未保存落库前,只允许编辑基础字段并点击“保存项目”;此时不展示“启动任务”或“完成任务”
4. 点击“启动项目”后,前端直接调用保存接口,状态切换为 `in_progress`,并自动带入 `actual_start_date=当天`;保存成功后关闭弹窗,如需调整实际开始日期可再次打开编辑
5. 点击“完成项目”后,前端直接调用保存接口,状态切换为 `completed`,并自动带入 `actual_finish_date=当天`;如 `actual_start_date` 为空也会一并补当天,完成进度不足 100 时自动补到 100,保存成功后关闭弹窗
6. 项目完成时,如当前完成进度小于 100,可默认补到 100
6. 手动填写实际完成日期时,表单立即将项目状态切换为 `completed` 并在该日期存在期间锁定状态选择;保存层会再次按同一规则校正,并沿用完成项目规则在完成进度小于 100 时自动补到 100。清空实际完成日期后解除状态锁定,但不自动回退项目状态,需由用户按实际情况选择。
7. 项目新建/编辑使用 4xl 弹窗;枚举字段从 Subject 配置进入可搜索选项弹层,用户字段进入远程搜索弹层,日期字段进入日期面板
8. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;已有项目的删除入口位于编辑态 Footer 右侧操作组、排在“取消”之前,点击后通过确认弹窗执行原删除流程;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的 `completion_percent`
9. 若已超过计划结束日期仍未完成,补充 `delay_reason`
......@@ -155,18 +156,19 @@
### 5.1 甘特图
- 以 `pm_project_task` 为数据源
- 展示项目名称、项目负责人、计划时间、实际时间、项目状态,以及按时间规则派生的结果状态
- 展示项目名称、项目负责人、计划时间、实际时间和单一的“展示状态”;不在甘特图中并列“项目状态 / 结果状态”
- 甘特图展示状态不单独入库,由 `project_status` 与日期派生结果合并,优先级为:已取消 → 提前/准时/超时完成 → 受阻(如同时超时则附带超时天数)→ 已超时 → 未开始 → 进行中 → 草稿
- 同时展示两个百分比:
- 时间进度百分比:按 `planned_start_date` 到 `planned_end_date` 与当前日期的关系自动计算
- 实际进度百分比:读取 `completion_percent`
- 已完成、超时、提前完成等状态需要有明显视觉区分
- 甘特图使用周/月/年 Tabs 切换时间窗口,并保留上一时间段、回到今天、下一时间段操作;窄视口下时间轴只在模块内部横向滚动,不产生页面级横向溢出
- 每条项目排期同时展示语义状态、负责人、计划日期、实际进度和时间进度;点击项目名称区域继续打开原项目编辑弹窗
- 每条项目排期展示一个统一状态标签、负责人、协作人、计划日期、实际进度和时间进度;负责人与协作人均按多值用户数组完整展示,多人时允许摘要区自然换行并同步撑高时间轴行;点击项目名称区域继续打开原项目编辑弹窗
- 甘特图条内展示最新一周的周进度总结;如存在多条周记录,以最新一条摘要为准
- 周进度记录数量可作为辅助信息一并展示,帮助判断项目是否持续更新
- 周进度明细通过项目编辑弹窗内的记录列表和新增/编辑弹窗维护
- 当前时间窗口没有匹配项目时展示空态提示,不生成测试项目或伪造业务数据
- 后续可按状态映射颜色,不必先存颜色字段
- 颜色与甘特图统一展示状态同步映射,并始终同时显示文字标签,不单独存储颜色字段或仅依赖颜色传达状态
### 5.2 个人日历图
......@@ -174,8 +176,10 @@
- 展示项目名称、计划时间、项目状态、共享进度
- 月历使用 HeroUI Pro `Agenda` 月视图 compound API;月份导航、今天定位、星期表头、非本月弱化、今日状态、月份单元和项目事件均由 Agenda 管理
- 日历图按 `planned_start_date` 到 `planned_end_date` 生成连续区间条,不再按天重复拆成多个独立卡片
- 当项目跨周时,区间条在对应周内自动拆段显示,但视觉上保持连续排期的阅读方式
- 当项目跨周时,区间条在对应周内自动拆段显示;当前月首次可见的区间段展示完整项目名称、状态和进度,后续周段仅标记“持续中”,避免把同一项目误读成多条重复记录
- 日历事件按 Record `_id` 去重;同一查询结果中重复返回的相同记录只生成一个日历事件,不合并 `_id` 不同的真实项目记录
- 项目事件为只读,点击后继续打开原项目编辑流程;日历不提供拖动、缩放、新建或删除入口,不改变任何项目日期和状态
- 从个人日历点击项目打开弹窗时保留当前 Tab 和浏览月份;取消、关闭或保存成功后仍停留在该月份的个人日历,不自动切换到项目甘特图。保存失败时保留弹窗并展示错误提示。
- 共享进度与共享状态对负责人和协作人一致
- 如项目存在最新周进度,可在详情或弹窗中查看周记录内容
......@@ -236,6 +240,7 @@ const result = await queryUsers(0, 20, {
- 点击“批量导入”后打开导入弹窗;文件通过 DropZone 选择或拖入,格式校验和预览完成前“开始导入”保持禁用
- 解析成功后先展示 DataGrid 预览,用户确认后才调用原有批量导入接口;取消弹窗不会写入业务数据
- 预览表的“序号”是当前可见导入数据的连续序号,从 1 开始,不表示 Excel 原始行号
- Excel 模板采用业务列头:`大类`、`项目`、`是否属于年度审计计划`、`发生频率`、`任务名称`、`任务来源`、`任务性质`、`计划启动时间`、`计划完成时间`、`时间进度`、`任务进度`、`任务状态`、`主办人`、`协办人1`
- `大类` 导入到 `metadata.big_category`,当前仅支持 `部门管理`、`纪检工作`、`审计工作`
......@@ -243,7 +248,7 @@ const result = await queryUsers(0, 20, {
- `任务名称` 导入到 `metadata.project_name`
- `是否属于年度审计计划` 导入到 `metadata.is_annual_audit_plan`,支持 `是/否`、`true/false`、`1/0`
- `主办人` 导入到 `metadata.project_owner`,`协办人1` 导入到 `metadata.project_collaborator`;支持填写系统用户 `display_name` 或 `username`,前端会先按 `username/display_name` 的 `$regex` 条件调用 `queryUsers` 查询,并最终保存为用户 `_id` 数组
- `计划启动时间` / `计划完成时间` 支持 Excel 日期、`YYYY-MM-DD`、`YYYY/MM/DD`、`2026年5月1日`、`5月1日`;入库时统一转换为 ISO 8601
- `计划启动时间` / `计划完成时间` 支持 Excel 日期、`YYYY-MM-DD`、`YYYY/MM/DD`、`2026年5月1日`、`5月1日`;Excel 日期序列值和无时区的文本日期均按日历日解析,不做 UTC 时区换算,入库时统一转换为 ISO 8601
- `任务进度` 支持 `20` 或 `20%`,导入到 `metadata.completion_percent`
- `任务状态` 支持中文状态值,导入时转换为 `draft` / `in_progress` / `completed` / `blocked` / `cancelled`
- `时间进度` 属于前端派生展示值,不参与系统计算;为避免丢失,导入时仅保留在 `remark`
......@@ -271,7 +276,7 @@ const result = await queryUsers(0, 20, {
### 8.2 查询与筛选
- 页面按“消息/待办”分类,按“全部/未读”状态查看。
- 内容搜索输入后 300ms 发起查询,关键词作为转义后的 `content.$regex` 传入;切换分类、状态或关键词均回到第 1 页。
- 内容搜索输入后 300ms 发起查询,关键词作为转义后的 `content.$regex` 传入,使用服务端默认匹配规则(不指定忽略大小写选项);切换分类、状态或关键词均回到第 1 页。
- 每页固定 10 条,使用 `POST /notice/customer/query`;未读状态通过接口 `status: 'unread'` 查询,不对当前页做前端过滤。
### 8.3 已读、待办与跳转
......
......@@ -407,6 +407,18 @@ export const PROJECT_TASK_IMPORT_EXAMPLE_ROW = [
'牟文博',
];
const PROJECT_TASK_IMPORT_DATE_HEADERS = [
'计划启动时间',
'计划开始日期',
'计划完成时间',
'计划结束日期',
'实际开始日期',
'实际完成日期',
'周进度开始日期',
'周进度结束日期',
'周进度记录日期',
];
export function buildProjectTaskImportWorkbook() {
const workbook = XLSX.utils.book_new();
const templateSheet = XLSX.utils.aoa_to_sheet([
......@@ -465,10 +477,28 @@ export async function readProjectTaskImportRows(file) {
return [];
}
return XLSX.utils
.sheet_to_json(sheet, {
defval: '',
raw: false,
const formattedRows = XLSX.utils.sheet_to_json(sheet, {
defval: '',
raw: false,
});
const rawRows = XLSX.utils.sheet_to_json(sheet, {
defval: '',
raw: true,
});
return formattedRows
.map((row, rowIndex) => {
const rawRow = rawRows[rowIndex] || {};
const normalizedRow = { ...row };
PROJECT_TASK_IMPORT_DATE_HEADERS.forEach((header) => {
const rawValue = rawRow[header];
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
normalizedRow[header] = rawValue;
}
});
return normalizedRow;
})
.filter((row) =>
Object.values(row || {}).some((value) => String(value ?? '').trim() !== ''),
......@@ -619,31 +649,26 @@ export function buildTaskFilter({
{
'metadata.project_name': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.big_category': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.project': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.task_source': {
$regex: keyword,
$options: 'i',
},
},
{
'metadata.task_nature': {
$regex: keyword,
$options: 'i',
},
},
],
......@@ -1063,9 +1088,11 @@ export function buildTaskPayload(values) {
const projectCollaborator = normalizeUserReferenceList(values.project_collaborator);
const plannedStartDate = formatDateInput(values.planned_start_date);
const plannedEndDate = formatDateInput(values.planned_end_date);
const projectStatus = values.project_status || 'draft';
const actualStartDateInput = formatDateInput(values.actual_start_date);
const actualFinishDateInput = formatDateInput(values.actual_finish_date);
const projectStatus = actualFinishDateInput
? 'completed'
: values.project_status || 'draft';
const today = new Date().toISOString().slice(0, 10);
const serializedWeeklyUpdates = serializeWeeklyUpdatesValue(values.weekly_updates, {
throwOnInvalid: true,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment