Commit f2f393a4 authored by drigle's avatar drigle

feat: 审计项目驾驶舱支持年份切换

parent 5ba517d6
...@@ -23,6 +23,7 @@ import { ...@@ -23,6 +23,7 @@ import {
isFutureAuditProjectView, isFutureAuditProjectView,
} from './AuditProjectService'; } from './AuditProjectService';
import { queryAllSubjectRecords } from './SubjectWorkbenchService'; import { queryAllSubjectRecords } from './SubjectWorkbenchService';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
const STATUS_TONES = { const STATUS_TONES = {
待启动: 'default', 待启动: 'default',
...@@ -34,12 +35,39 @@ const STATUS_TONES = { ...@@ -34,12 +35,39 @@ const STATUS_TONES = {
}; };
const STATUS_CHART_ORDER = ['计划', '执行', '归档', '整改', '待启动', '完成', '未设置']; const STATUS_CHART_ORDER = ['计划', '执行', '归档', '整改', '待启动', '完成', '未设置'];
const YEAR_SOURCE_FIELDS = [
'planned_start_up_time',
'actual_implementation_time',
'project_completion_date',
'report_date',
];
function getStatusChartOrder(status) { function getStatusChartOrder(status) {
const index = STATUS_CHART_ORDER.indexOf(status); const index = STATUS_CHART_ORDER.indexOf(status);
return index === -1 ? Number.MAX_SAFE_INTEGER : index; return index === -1 ? Number.MAX_SAFE_INTEGER : index;
} }
function extractYear(value) {
if (!value) return null;
const parsed = dayjs(value);
if (parsed.isValid()) return parsed.year();
const yearMatch = String(value).match(/(?:19|20)\d{2}/);
return yearMatch ? Number(yearMatch[0]) : null;
}
function extractRecordYear(record) {
const metadata = record?.metadata || {};
for (const field of YEAR_SOURCE_FIELDS) {
const year = extractYear(metadata[field]);
if (year) return year;
}
return extractYear(record?.created_at) || extractYear(record?.updated_at);
}
function MetricCard({ label, value, icon: Icon, tone, onPress }) { function MetricCard({ label, value, icon: Icon, tone, onPress }) {
const numericValue = Number.parseFloat(String(value).replace(/[% ,]/g, '')); const numericValue = Number.parseFloat(String(value).replace(/[% ,]/g, ''));
const content = ( const content = (
...@@ -272,6 +300,7 @@ export default function AuditProjectDashboardPage() { ...@@ -272,6 +300,7 @@ export default function AuditProjectDashboardPage() {
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [activeYear, setActiveYear] = useState('all');
const [selectedRecordName, setSelectedRecordName] = useState(''); const [selectedRecordName, setSelectedRecordName] = useState('');
const [projectGroup, setProjectGroup] = useState(null); const [projectGroup, setProjectGroup] = useState(null);
...@@ -291,32 +320,45 @@ export default function AuditProjectDashboardPage() { ...@@ -291,32 +320,45 @@ export default function AuditProjectDashboardPage() {
useEffect(() => { loadDashboard(); }, [loadDashboard]); useEffect(() => { loadDashboard(); }, [loadDashboard]);
const dashboard = useMemo(() => { const dashboard = useMemo(() => {
const completed = records.filter((record) => ['整改', '归档'].includes(record?.metadata?.project_status)); const yearRecords = records.map((record) => ({
const unfinished = records.filter((record) => !['整改', '归档'].includes(record?.metadata?.project_status)); record,
const planned = records.filter((record) => record?.metadata?.project_status === '计划'); recordYear: extractRecordYear(record),
const executing = records.filter((record) => record?.metadata?.project_status === '执行'); }));
const finished = records.filter((record) => record?.metadata?.project_status === '完成'); const yearOptions = Array.from(
const overdue = records.filter((record) => { new Set(yearRecords.map(({ recordYear }) => recordYear).filter(Number.isInteger)),
).sort((left, right) => right - left);
const filteredRecords = (activeYear === 'all'
? yearRecords
: yearRecords.filter(({ recordYear }) => recordYear === Number(activeYear)))
.map(({ record }) => record);
const completed = filteredRecords.filter((record) => ['整改', '归档'].includes(record?.metadata?.project_status));
const unfinished = filteredRecords.filter((record) => !['整改', '归档'].includes(record?.metadata?.project_status));
const planned = filteredRecords.filter((record) => record?.metadata?.project_status === '计划');
const executing = filteredRecords.filter((record) => record?.metadata?.project_status === '执行');
const finished = filteredRecords.filter((record) => record?.metadata?.project_status === '完成');
const overdue = filteredRecords.filter((record) => {
const metadata = record?.metadata || {}; const metadata = record?.metadata || {};
return !['整改', '归档'].includes(metadata.project_status) return !['整改', '归档'].includes(metadata.project_status)
&& metadata.project_completion_date && metadata.project_completion_date
&& dayjs(metadata.project_completion_date).isBefore(dayjs(), 'day'); && dayjs(metadata.project_completion_date).isBefore(dayjs(), 'day');
}); });
const active = records.filter((record) => ['计划', '执行', '完成'].includes(record?.metadata?.project_status)); const active = filteredRecords.filter((record) => ['计划', '执行', '完成'].includes(record?.metadata?.project_status));
const upcoming = records.filter((record) => { const upcoming = filteredRecords.filter((record) => {
const metadata = record?.metadata || {}; const metadata = record?.metadata || {};
if (metadata.project_status !== '待启动' || !metadata.planned_start_up_time) return false; if (metadata.project_status !== '待启动' || !metadata.planned_start_up_time) return false;
const daysUntilStart = dayjs(metadata.planned_start_up_time).startOf('day').diff(dayjs().startOf('day'), 'day'); const daysUntilStart = dayjs(metadata.planned_start_up_time).startOf('day').diff(dayjs().startOf('day'), 'day');
return daysUntilStart > 0 && daysUntilStart <= 30; return daysUntilStart > 0 && daysUntilStart <= 30;
}); });
const statusCounts = records.reduce((counts, record) => { const statusCounts = filteredRecords.reduce((counts, record) => {
const status = record?.metadata?.project_status || '未设置'; const status = record?.metadata?.project_status || '未设置';
counts[status] = (counts[status] || 0) + 1; counts[status] = (counts[status] || 0) + 1;
return counts; return counts;
}, {}); }, {});
return { return {
yearOptions,
recordCount: filteredRecords.length,
completed, unfinished, planned, executing, finished, overdue, active, upcoming, completed, unfinished, planned, executing, finished, overdue, active, upcoming,
completionRate: records.length ? Math.round((completed.length / records.length) * 100) : 0, completionRate: filteredRecords.length ? Math.round((completed.length / filteredRecords.length) * 100) : 0,
statusData: Object.entries(statusCounts).map(([name, value]) => ({ name, value })), statusData: Object.entries(statusCounts).map(([name, value]) => ({ name, value })),
stageData: [ stageData: [
{ name: '计划', value: planned.length }, { name: '计划', value: planned.length },
...@@ -324,7 +366,15 @@ export default function AuditProjectDashboardPage() { ...@@ -324,7 +366,15 @@ export default function AuditProjectDashboardPage() {
{ name: '完成', value: finished.length }, { name: '完成', value: finished.length },
], ],
}; };
}, [records]); }, [activeYear, records]);
useEffect(() => {
if (activeYear !== 'all' && !dashboard.yearOptions.includes(Number(activeYear))) {
setActiveYear('all');
}
}, [activeYear, dashboard.yearOptions]);
const yearLabel = activeYear === 'all' ? '全部年份' : `${activeYear} 年`;
const openRecord = (record) => setSelectedRecordName(record?.name || record?._id || ''); const openRecord = (record) => setSelectedRecordName(record?.name || record?._id || '');
const openGroup = (title, groupRecords) => setProjectGroup({ title, records: groupRecords }); const openGroup = (title, groupRecords) => setProjectGroup({ title, records: groupRecords });
...@@ -346,8 +396,35 @@ export default function AuditProjectDashboardPage() { ...@@ -346,8 +396,35 @@ export default function AuditProjectDashboardPage() {
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
<div className="flex justify-end">
<XinyuanAutocomplete
aria-label="统计年份"
className="w-36"
fullWidth={false}
options={[
{ id: 'all', label: '全部年份' },
...dashboard.yearOptions.map((year) => ({
id: String(year),
label: `${year} 年`,
})),
]}
placement="bottom end"
placeholder="全部年份"
selectedKey={activeYear}
shouldFilter={false}
showSearch={false}
valueText={yearLabel}
variant="primary"
onSelectionChange={(key) => {
if (key !== null && key !== undefined) {
setActiveYear(String(key));
}
}}
/>
</div>
<section className="rc-kpi-grid" data-count="4"> <section className="rc-kpi-grid" data-count="4">
<MetricCard label="项目总数" value={records.length} icon={FolderOpenIcon} tone="indigo" /> <MetricCard label={`${yearLabel}项目总数`} value={dashboard.recordCount} icon={FolderOpenIcon} tone="indigo" />
<MetricCard label="已完成" value={dashboard.completed.length} icon={CheckCircleIcon} tone="success" onPress={() => openGroup('已完成项目', dashboard.completed)} /> <MetricCard label="已完成" value={dashboard.completed.length} icon={CheckCircleIcon} tone="success" onPress={() => openGroup('已完成项目', dashboard.completed)} />
<MetricCard label="未完成" value={dashboard.unfinished.length} icon={ClockIcon} tone="warning" onPress={() => openGroup('未完成项目', dashboard.unfinished)} /> <MetricCard label="未完成" value={dashboard.unfinished.length} icon={ClockIcon} tone="warning" onPress={() => openGroup('未完成项目', dashboard.unfinished)} />
<MetricCard label="完成率" value={`${dashboard.completionRate}%`} icon={ChartPieIcon} tone="teal" /> <MetricCard label="完成率" value={`${dashboard.completionRate}%`} icon={ChartPieIcon} tone="teal" />
......
...@@ -85,6 +85,14 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template` ...@@ -85,6 +85,14 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
## 6. 驾驶舱口径 ## 6. 驾驶舱口径
- 驾驶舱顶部提供“统计年份”选择器,默认“全部年份”;年份选项从当前已加载的
`audit_project` 记录自动提取并倒序展示。
- 记录年份优先依次读取 `metadata.planned_start_up_time`、`metadata.actual_implementation_time`、
`metadata.project_completion_date`、`metadata.report_date`;均无法识别时回退到记录
`created_at` / `updated_at`。
- 页面通过既有 `POST /record/query` 全量加载记录后,在前端完成年份过滤,并计算项目总数、
完成率、状态分布、阶段分布和项目提醒。
- 记录详情刷新后重新加载全量记录;若当前所选年份已无对应记录,则自动回退“全部年份”。
- 已完成:状态为 `整改` 或 `归档`。 - 已完成:状态为 `整改` 或 `归档`。
- 未完成:状态不是 `整改` 且不是 `归档`。 - 未完成:状态不是 `整改` 且不是 `归档`。
- 完成率:已完成项目数除以项目总数,四舍五入为整数百分比。 - 完成率:已完成项目数除以项目总数,四舍五入为整数百分比。
......
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