Commit c79b1658 authored by Andy-bubu's avatar Andy-bubu

fix: restore gantt view and checkbox contrast

parent 52b17cdc
import clsx from 'clsx';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanDatePicker from '@/components/XinyuanDatePicker'; import XinyuanDatePicker from '@/components/XinyuanDatePicker';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
...@@ -611,6 +612,22 @@ function buildGanttColumns(anchorDate, viewType) { ...@@ -611,6 +612,22 @@ function buildGanttColumns(anchorDate, viewType) {
}; };
} }
function formatGanttRangeTitle(anchorDate, viewType) {
const anchor = createDate(anchorDate);
if (viewType === GANTT_VIEW_TYPES.WEEK) {
const start = startOfIsoWeek(anchor);
const end = endOfIsoWeek(anchor);
return `${formatDateKey(start)} ~ ${formatDateKey(end)}`;
}
if (viewType === GANTT_VIEW_TYPES.YEAR) {
return `${anchor.getFullYear()}年`;
}
return `${anchor.getFullYear()}年${padNumber(anchor.getMonth() + 1)}月`;
}
function moveGanttAnchor(anchorDate, viewType, direction) { function moveGanttAnchor(anchorDate, viewType, direction) {
const anchor = createDate(anchorDate); const anchor = createDate(anchorDate);
...@@ -715,226 +732,161 @@ function FilterField({ label, children }) { ...@@ -715,226 +732,161 @@ function FilterField({ label, children }) {
</div> </div>
); );
} }
function getGanttStatusPresentation(task) { function getTaskTone(task) {
if (task.projectStatus === 'blocked') { if (task.resultStatus === 'completed_early') {
return { color: 'warning', label: '受阻' }; 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 (task.resultStatus === 'overdue' || task.resultStatus === 'completed_overdue') { return {
return { color: 'danger', label: task.resultStatusLabel }; surface: 'bg-rose-50 border-rose-200',
accent: 'bg-rose-500',
text: 'text-rose-700',
};
} }
if (task.projectStatus === 'blocked') {
if (task.resultStatus === 'completed_early') { return {
return { color: 'success', label: '提前完成' }; surface: 'bg-amber-50 border-amber-200',
accent: 'bg-amber-500',
text: 'text-amber-700',
};
} }
if (task.projectStatus === 'completed') { if (task.projectStatus === 'completed') {
return { color: 'success', label: '已完成' }; return {
} surface: 'bg-rose-50 border-rose-200',
accent: 'bg-rose-500',
if (task.resultStatus === 'not_started') { text: 'text-rose-700',
return { color: 'default', label: '未开始' }; };
} }
return {
return { color: 'accent', label: '正常进行' }; surface: 'bg-zinc-50 border-zinc-200',
accent: 'bg-[var(--pm-brand)]',
text: 'text-zinc-700',
};
} }
function clampProgressValue(value) { function GanttLegend() {
return Math.max(0, Math.min(100, Number(value) || 0)); 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-amber-500' },
];
return (
<div className="flex flex-wrap items-center gap-3">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2 text-xs text-zinc-500">
<span className={clsx('h-2.5 w-2.5 rounded-full', item.className)} />
{item.label}
</div>
))}
</div>
);
} }
function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask }) { function ProjectGanttBoard({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
const [viewType, setViewType] = useState(GANTT_VIEW_TYPES.MONTH); const [viewType, setViewType] = useState(GANTT_VIEW_TYPES.MONTH);
const [viewAnchor, setViewAnchor] = useState(resolveCurrentDate()); const [viewAnchor, setViewAnchor] = useState(resolveCurrentDate());
const timelineMeta = useMemo( const activeTasks = tasks.filter(
() => buildGanttColumns(viewAnchor, viewType), (task) => task.plannedStartDate && task.plannedEndDate,
[viewAnchor, viewType],
); );
const activeTasks = tasks.filter((task) => task.plannedStartDate && task.plannedEndDate); const timelineMeta = useMemo(() => buildGanttColumns(viewAnchor, viewType), [viewAnchor, viewType]);
const visibleTasks = activeTasks.filter((task) => const visibleTasks = useMemo(() => activeTasks.filter((task) => timelineMeta.columns.some(
timelineMeta.columns.some( (column) => column.endKey >= task.plannedStartDate && column.startKey <= task.plannedEndDate,
(column) => )), [activeTasks, timelineMeta.columns]);
column.endKey >= task.plannedStartDate && const columnMinWidth = useMemo(() => getGanttColumnMinWidth(viewType), [viewType]);
column.startKey <= task.plannedEndDate, const timelineGridTemplate = useMemo(
), () => `280px repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
[columnMinWidth, timelineMeta.columns.length],
); );
const columnMinWidth = getGanttColumnMinWidth(viewType); const rangeTitle = useMemo(() => formatGanttRangeTitle(viewAnchor, viewType), [viewAnchor, viewType]);
const timelineGridStyle = {
gridTemplateColumns: `240px repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
};
const timelineMinWidth = 240 + timelineMeta.columns.length * columnMinWidth;
return ( return (
<Card className="rc-card-surface rc-dashboard-card"> <div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<Card.Header className="flex-col items-stretch gap-4 xl:flex-row xl:items-center"> <div className="border-b border-zinc-200 px-5 py-4">
<div className="min-w-0 flex-1"> <div className="flex flex-wrap items-start justify-between gap-4">
<Card.Title className="rc-module-title">项目甘特图</Card.Title> <div><div className="text-lg font-semibold text-zinc-900">项目甘特图</div></div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-full border border-zinc-200 bg-zinc-100/80 p-1">
{GANTT_VIEW_OPTIONS.map((option) => (
<button key={option.value} type="button" onClick={() => setViewType(option.value)} className={clsx(
'rounded-full px-4 py-1.5 text-sm font-medium transition',
viewType === option.value ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-800',
)}>{option.label}</button>
))}
</div>
<GanttLegend />
</div>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3 sm:justify-end"> <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={viewType}
onSelectionChange={(key) => setViewType(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="甘特图时间范围" className="w-max min-w-max">
{GANTT_VIEW_OPTIONS.map((option) => (
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={option.value}
key={option.value}
>
{option.label}<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip delay={0}> <button type="button" onClick={() => setViewAnchor(moveGanttAnchor(viewAnchor, viewType, -1))} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50">上一段</button>
<Button <div className="min-w-[180px] text-center text-sm font-semibold text-zinc-900">{rangeTitle}</div>
isIconOnly <button type="button" onClick={() => setViewAnchor(moveGanttAnchor(viewAnchor, viewType, 1))} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50">下一段</button>
aria-label="上一时间段"
size="sm"
variant="tertiary"
onPress={() => setViewAnchor(moveGanttAnchor(viewAnchor, viewType, -1))}
>
<Back size={16} />
</Button>
<Tooltip.Content>上一时间段</Tooltip.Content>
</Tooltip>
<Button size="sm" variant="secondary" onPress={() => setViewAnchor(resolveCurrentDate())}>
今天
</Button>
<Tooltip delay={0}>
<Button
isIconOnly
aria-label="下一时间段"
size="sm"
variant="tertiary"
onPress={() => setViewAnchor(moveGanttAnchor(viewAnchor, viewType, 1))}
>
<ChevronRight size={16} />
</Button>
<Tooltip.Content>下一时间段</Tooltip.Content>
</Tooltip>
</div> </div>
<button type="button" onClick={() => setViewAnchor(resolveCurrentDate())} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-600 transition hover:bg-zinc-50">回到今天</button>
</div> </div>
</Card.Header> </div>
<Card.Content className="grid gap-4"> <div className="overflow-auto">
<Surface className="rc-nested-surface min-w-0 p-3 sm:p-4" variant="secondary"> <div className="grid min-w-[1080px]" style={{ gridTemplateColumns: timelineGridTemplate }}>
{visibleTasks.length ? ( <div className="sticky left-0 z-10 border-b border-r border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-600">项目 / 状态</div>
<div className="w-full min-w-0 overflow-x-auto"> {timelineMeta.columns.map((column) => (
<div className="grid gap-2" style={{ minWidth: `${timelineMinWidth}px` }}> <div key={column.key} className={clsx('border-b border-r border-zinc-200 px-2 py-3 text-center', column.isToday ? 'bg-rose-50 text-rose-700' : 'bg-zinc-50 text-zinc-500')}>
<div className="grid items-stretch gap-1" style={timelineGridStyle}> <div className="text-sm font-semibold leading-none">{column.primary}</div>
<Surface className="rc-nested-surface flex items-center px-3 py-2" variant="tertiary"> <div className="mt-1 text-[11px] leading-none text-inherit/80">{column.secondary}</div>
<span className="rc-data-grid-header">项目</span> </div>
</Surface> ))}
{timelineMeta.columns.map((column) => ( {visibleTasks.length > 0 ? visibleTasks.map((task) => {
<Surface const tone = getTaskTone(task);
key={column.key} const taskId = getTaskRecordId(task);
className="rc-nested-surface flex min-w-0 flex-col items-center justify-center px-1 py-2 text-center" const visibleStartIndex = timelineMeta.columns.findIndex((column) => column.endKey >= task.plannedStartDate);
variant="tertiary" const visibleEndIndex = [...timelineMeta.columns].reverse().findIndex((column) => column.startKey <= task.plannedEndDate);
> const startIndex = visibleStartIndex;
{column.isToday ? ( const endIndex = visibleEndIndex === -1 ? -1 : timelineMeta.columns.length - visibleEndIndex - 1;
<Chip color="accent" size="sm" variant="soft">{column.primary}</Chip> const hasVisibleBar = startIndex !== -1 && endIndex !== -1 && endIndex >= startIndex;
) : ( const span = hasVisibleBar ? endIndex - startIndex + 1 : 0;
<span className="rc-data-grid-primary">{column.primary}</span> return (
)} <div key={taskId} className="contents">
<span className="rc-data-grid-secondary">{column.secondary}</span> <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' : '')}>
</Surface> <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>
</div>
</button>
<div className="relative 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')} />)}
</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}` }}>
<button type="button" onClick={() => { onSelectTask(taskId); onEditTask(task); }} className={clsx('relative flex h-auto w-full items-center rounded-[16px] border px-3 py-1.5 text-left shadow-sm transition hover:brightness-[0.98]', tone.surface, selectedTaskId === taskId ? 'ring-2 ring-rose-300' : 'ring-1 ring-transparent')} style={{ height: viewType === GANTT_VIEW_TYPES.YEAR ? '56px' : '62px' }}>
<div className={clsx('mr-2.5 h-4 w-1 shrink-0 rounded-full', tone.accent)} />
<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>
</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>
</button>
</div>
</div> : null}
</div> </div>
{visibleTasks.map((task) => {
const startIndex = timelineMeta.columns.findIndex(
(column) => column.endKey >= task.plannedStartDate,
);
const endIndex = timelineMeta.columns.findLastIndex(
(column) => column.startKey <= task.plannedEndDate,
);
const taskId = getTaskRecordId(task);
const status = getGanttStatusPresentation(task);
const actualProgress = clampProgressValue(task.progressPercent);
const plannedProgress = clampProgressValue(task.plannedProgressPercent);
return (
<Surface
key={taskId}
className="rc-nested-surface grid min-h-28 items-center gap-1 p-2"
style={timelineGridStyle}
variant="tertiary"
>
<Button
aria-current={taskId === selectedTaskId ? 'true' : undefined}
className="h-full min-w-0 justify-start"
variant={taskId === selectedTaskId ? 'secondary' : 'ghost'}
onPress={() => {
onSelectTask(taskId);
onEditTask(task);
}}
>
<span className="grid min-w-0 gap-1 text-left">
<span className="rc-data-grid-primary block truncate">{task.projectName}</span>
<span className="flex min-w-0 items-center gap-2">
<Chip color={status.color} size="sm" variant="soft">{status.label}</Chip>
<span className="rc-data-grid-secondary truncate">{formatUserLabel(task.projectOwnerUser)}</span>
</span>
<span className="rc-data-grid-secondary block truncate">
{task.plannedStartDate} ~ {task.plannedEndDate}
</span>
</span>
</Button>
<div
className="grid min-w-0 gap-2 px-2 py-1"
style={{
gridColumn: `${Math.max(0, startIndex) + 2} / ${Math.max(startIndex, endIndex) + 3}`,
}}
>
<ProgressBar
aria-label={`${task.projectName}实际进度`}
color={status.color}
size="sm"
value={actualProgress}
>
<ProgressBar.Output>实际 {formatPercent(actualProgress)}</ProgressBar.Output>
<ProgressBar.Track><ProgressBar.Fill /></ProgressBar.Track>
</ProgressBar>
<ProgressBar
aria-label={`${task.projectName}时间进度`}
color="default"
size="sm"
value={plannedProgress}
>
<ProgressBar.Output>时间 {formatPercent(plannedProgress)}</ProgressBar.Output>
<ProgressBar.Track><ProgressBar.Fill /></ProgressBar.Track>
</ProgressBar>
<Tooltip delay={0}>
<Tooltip.Trigger className="min-w-0">
<span className="rc-data-grid-secondary block truncate">
{task.latestWeeklySummary || task.delayReason || '暂无周进展'}
{task.weeklyUpdateCount ? ` · 共 ${task.weeklyUpdateCount} 条` : ''}
</span>
</Tooltip.Trigger>
<Tooltip.Content>
{task.latestWeeklySummary || task.delayReason || '暂无周进展'}
</Tooltip.Content>
</Tooltip>
</div>
</Surface>
);
})}
</div> </div>
</div> );
) : ( }) : <div className="col-span-full px-5 py-16 text-center text-sm text-zinc-400">当前时间窗口内没有可展示的项目进度</div>}
<EmptyState className="min-h-64" size="md"> </div>
<EmptyState.Header> </div>
<EmptyState.Title>当前时间范围没有项目</EmptyState.Title> </div>
<EmptyState.Description>可切换周、月或年视图查看其它计划周期。</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
)}
</Surface>
</Card.Content>
</Card>
); );
} }
...@@ -2016,11 +1968,14 @@ function ProjectTaskDialog({ ...@@ -2016,11 +1968,14 @@ function ProjectTaskDialog({
<span className="font-medium">是否属于年度审计计划</span> <span className="font-medium">是否属于年度审计计划</span>
<Checkbox <Checkbox
isSelected={formValues.is_annual_audit_plan} isSelected={formValues.is_annual_audit_plan}
variant="secondary"
onChange={(isSelected) => updateField('is_annual_audit_plan', isSelected)} onChange={(isSelected) => updateField('is_annual_audit_plan', isSelected)}
> >
<Checkbox.Content> <Checkbox.Content>
<Checkbox.Control><Checkbox.Indicator /></Checkbox.Control> <Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>
属于年度审计计划 <div>
<div data-slot="label">属于年度审计计划</div>
</div>
</Checkbox.Content> </Checkbox.Content>
</Checkbox> </Checkbox>
</div> </div>
......
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