Commit ca0cbf30 authored by David Yang's avatar David Yang

feat: 完善项目管理工作台与资源统计

parent 70e6803d
...@@ -12,11 +12,14 @@ ...@@ -12,11 +12,14 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
"umi-request": "^1.4.0", "umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0" "umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.33", "@types/react": "^18.0.33",
...@@ -7306,6 +7309,15 @@ ...@@ -7306,6 +7309,15 @@
"node": ">=8.9.0" "node": ">=8.9.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
...@@ -8512,6 +8524,19 @@ ...@@ -8512,6 +8524,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
...@@ -8677,6 +8702,15 @@ ...@@ -8677,6 +8702,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
...@@ -8929,6 +8963,18 @@ ...@@ -8929,6 +8963,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-ecdh": { "node_modules/create-ecdh": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
...@@ -10055,6 +10101,36 @@ ...@@ -10055,6 +10101,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
...@@ -11384,6 +11460,15 @@ ...@@ -11384,6 +11460,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
...@@ -18260,6 +18345,12 @@ ...@@ -18260,6 +18345,12 @@
"integrity": "sha512-9SQdmsyz4HSP+3gs6PJzhkaMEg+6zTlu9oxIghnwUX3eq+ajq4ft5egl0iyR55LAmO/UwvU8NgIWs/ZyQMa6dw==", "integrity": "sha512-9SQdmsyz4HSP+3gs6PJzhkaMEg+6zTlu9oxIghnwUX3eq+ajq4ft5egl0iyR55LAmO/UwvU8NgIWs/ZyQMa6dw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
...@@ -18493,6 +18584,18 @@ ...@@ -18493,6 +18584,18 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable": { "node_modules/stable": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
...@@ -20739,6 +20842,24 @@ ...@@ -20739,6 +20842,24 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
...@@ -20823,6 +20944,27 @@ ...@@ -20823,6 +20944,27 @@
} }
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
...@@ -20924,6 +21066,21 @@ ...@@ -20924,6 +21066,21 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.18.0" "zod": "^3.18.0"
} }
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
} }
} }
} }
...@@ -14,11 +14,14 @@ ...@@ -14,11 +14,14 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
"umi-request": "^1.4.0", "umi-request": "^1.4.0",
"umi-request-progress": "^1.1.0" "umi-request-progress": "^1.1.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.33", "@types/react": "^18.0.33",
......
:root { :root {
--pm-brand: #009cd9; --pm-brand: #b42318;
--pm-brand-strong: #007bb8; --pm-brand-strong: #991b1b;
--pm-surface: #ffffff; --pm-surface: #ffffff;
--pm-bg: #eef5f9; --pm-bg: #f8fafc;
--pm-sidebar: #0f172a; --pm-sidebar: #0f172a;
--pm-sidebar-soft: #1e293b; --pm-sidebar-soft: #1e293b;
--pm-text: #0f172a; --pm-text: #0f172a;
...@@ -17,7 +17,10 @@ body, ...@@ -17,7 +17,10 @@ body,
body { body {
margin: 0; margin: 0;
background: linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%); background:
radial-gradient(circle at top left, rgba(251, 191, 188, 0.22), transparent 26%),
radial-gradient(circle at bottom right, rgba(251, 207, 232, 0.2), transparent 30%),
#f8fafc;
color: var(--pm-text); color: var(--pm-text);
font-family: font-family:
"PingFang SC", "PingFang SC",
......
export default function EmptyStatePage({ title, description }) { export default function EmptyStatePage({ title, description }) {
return ( return (
<section className="relative overflow-hidden rounded-[32px] border border-white/70 bg-white/90 p-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]"> <section className="relative overflow-hidden rounded-[32px] border border-white/70 bg-white/90 p-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
<div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-sky-100 blur-3xl" /> <div className="absolute -right-20 -top-20 h-56 w-56 rounded-full bg-rose-100 blur-3xl" />
<div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-cyan-50 blur-3xl" /> <div className="absolute bottom-0 left-0 h-40 w-40 rounded-full bg-amber-50 blur-3xl" />
<div className="relative"> <div className="relative">
<div className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-sky-700"> <div className="inline-flex rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-xs font-semibold tracking-[0.2em] text-rose-700">
COMING SOON COMING SOON
</div> </div>
<h2 className="mt-6 text-3xl font-semibold text-slate-900">{title}</h2> <h2 className="mt-6 text-3xl font-semibold text-slate-900">{title}</h2>
......
import rsmLogo from '@/assets/rsm_logo.svg'; import xyLogo from '@/assets/xy-logo.png';
import rsmLogoSmall from '@/assets/rsm_logo_small.svg'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { import {
CalendarDaysIcon, CalendarDaysIcon,
...@@ -220,7 +220,7 @@ export default function ProjectManagerLayout() { ...@@ -220,7 +220,7 @@ export default function ProjectManagerLayout() {
}, [location.pathname]); }, [location.pathname]);
return ( return (
<div className="flex h-screen overflow-hidden bg-zinc-50"> <div className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]">
<aside <aside
className={clsx( className={clsx(
'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex', 'relative z-30 hidden shrink-0 flex-col border-r border-zinc-800 bg-zinc-900 p-5 lg:flex',
...@@ -234,13 +234,13 @@ export default function ProjectManagerLayout() { ...@@ -234,13 +234,13 @@ export default function ProjectManagerLayout() {
)} )}
> >
<img <img
src={sidebarCollapsed ? rsmLogoSmall : rsmLogo} src={sidebarCollapsed ? xyLogoSmall : xyLogo}
alt="RSM Logo" alt="XY Logo"
className={clsx( className={clsx(
'shrink-0 object-contain', 'shrink-0 object-contain',
sidebarCollapsed sidebarCollapsed
? 'h-9 w-auto max-w-[52px]' ? 'h-8 w-auto max-w-8'
: 'h-5 w-auto max-w-[128px]', : 'h-8 w-auto max-w-[140px]',
)} )}
/> />
{!sidebarCollapsed ? ( {!sidebarCollapsed ? (
...@@ -302,9 +302,9 @@ export default function ProjectManagerLayout() { ...@@ -302,9 +302,9 @@ export default function ProjectManagerLayout() {
<div className="px-5 pt-6"> <div className="px-5 pt-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <img
src={rsmLogo} src={xyLogo}
alt="RSM Logo" alt="XY Logo"
className="h-5 w-auto max-w-[128px] shrink-0 object-contain" className="h-8 w-auto max-w-[140px] shrink-0 object-contain"
/> />
<h1 className="text-base font-semibold tracking-[0.02em] text-white"> <h1 className="text-base font-semibold tracking-[0.02em] text-white">
{APP_NAME} {APP_NAME}
...@@ -346,7 +346,7 @@ export default function ProjectManagerLayout() { ...@@ -346,7 +346,7 @@ export default function ProjectManagerLayout() {
<div className="text-sm text-zinc-500">{currentGroupTitle}</div> <div className="text-sm text-zinc-500">{currentGroupTitle}</div>
</div> </div>
<div className="flex-1 overflow-y-auto rounded-xl border border-zinc-200 bg-white p-4 sm:p-6"> <div className="flex-1 overflow-y-auto rounded-xl border border-zinc-200 bg-white/95 p-4 shadow-[0_20px_50px_rgba(15,23,42,0.06)] sm:p-6">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
......
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
buildDemoTasks, buildImportTaskPayload,
buildTaskPayload,
OCCURRENCE_FREQUENCY_OPTIONS,
fetchSystemUsersByIds, fetchSystemUsersByIds,
buildUserMap, buildUserMap,
enrichProjectTask, enrichProjectTask,
fetchProjectTasks, fetchProjectTasks,
formatDateInput, formatDateInput,
downloadProjectTaskImportTemplate,
loadProjectTaskFieldOptions,
mergeUserRecords, mergeUserRecords,
PRIORITY_OPTIONS, normalizeWeeklyUpdatesValue,
PRIORITY_LABEL_MAP, PRIORITY_LABEL_MAP,
PROJECT_STATUS_LABEL_MAP, PROJECT_STATUS_LABEL_MAP,
PROJECT_STATUS_OPTIONS, PROJECT_STATUS_OPTIONS,
RESULT_STATUS_OPTIONS, resolveUserReferenceId,
readProjectTaskImportRows,
resolveImportUserReferenceList,
importRecords,
saveProjectTask, saveProjectTask,
searchSystemUsers, searchSystemUsers,
sortWeeklyUpdates,
} from './projectTaskService'; } from './projectTaskService';
function resolveCurrentDate() { function resolveCurrentDate() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
function canStartProject(projectStatus) {
return projectStatus === 'draft';
}
function canCompleteProject(projectStatus) {
return projectStatus === 'in_progress';
}
function getTaskRecordId(task) { function getTaskRecordId(task) {
return task?._id || task?.id || task?.name || ''; return task?._id || task?.id || task?.name || '';
} }
...@@ -32,6 +48,17 @@ function formatUserLabel(user) { ...@@ -32,6 +48,17 @@ function formatUserLabel(user) {
return user.displayName || user.username || user.id || '-'; return user.displayName || user.username || user.id || '-';
} }
function EmptyWorkspaceNotice({ title, description }) {
return (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white px-6 py-12 text-center shadow-sm">
<div className="text-lg font-semibold text-zinc-900">{title}</div>
{description ? (
<div className="mt-2 text-sm leading-6 text-zinc-500">{description}</div>
) : null}
</div>
);
}
function UserSearchSelect({ function UserSearchSelect({
value, value,
onChange, onChange,
...@@ -110,7 +137,7 @@ function UserSearchSelect({ ...@@ -110,7 +137,7 @@ function UserSearchSelect({
setOpen(true); setOpen(true);
}} }}
placeholder={placeholder} placeholder={placeholder}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
/> />
{value ? ( {value ? (
<button <button
...@@ -161,7 +188,7 @@ function UserSearchSelect({ ...@@ -161,7 +188,7 @@ function UserSearchSelect({
function UserMultiSearchSelect({ function UserMultiSearchSelect({
value, value,
onChange, onChange,
placeholder = '远程搜索并选择成员', placeholder = '远程搜索用户',
}) { }) {
const selectedIds = Array.isArray(value) ? value.filter(Boolean) : []; const selectedIds = Array.isArray(value) ? value.filter(Boolean) : [];
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
...@@ -251,7 +278,7 @@ function UserMultiSearchSelect({ ...@@ -251,7 +278,7 @@ function UserMultiSearchSelect({
setOpen(true); setOpen(true);
}} }}
placeholder={placeholder} placeholder={placeholder}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
/> />
{open ? ( {open ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-auto rounded-2xl border border-zinc-200 bg-white p-2 shadow-[0_20px_50px_rgba(15,23,42,0.12)]"> <div className="absolute z-20 mt-2 max-h-64 w-full overflow-auto rounded-2xl border border-zinc-200 bg-white p-2 shadow-[0_20px_50px_rgba(15,23,42,0.12)]">
...@@ -270,15 +297,13 @@ function UserMultiSearchSelect({ ...@@ -270,15 +297,13 @@ function UserMultiSearchSelect({
}} }}
className={clsx( className={clsx(
'block w-full rounded-xl px-3 py-2 text-left transition', 'block w-full rounded-xl px-3 py-2 text-left transition',
checked ? 'bg-sky-50' : 'hover:bg-zinc-50', checked ? 'bg-rose-50' : 'hover:bg-zinc-50',
)} )}
> >
<div className="text-sm font-medium text-zinc-900"> <div className="text-sm font-medium text-zinc-900">
{formatUserLabel(user)} {formatUserLabel(user)}
</div> </div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">{user.username || user.id}</div>
{user.username || user.id}
</div>
</button> </button>
); );
}) })
...@@ -294,20 +319,20 @@ function UserMultiSearchSelect({ ...@@ -294,20 +319,20 @@ function UserMultiSearchSelect({
selectedUsers.map((user) => ( selectedUsers.map((user) => (
<span <span
key={user.id} key={user.id}
className="inline-flex items-center gap-2 rounded-full bg-sky-50 px-3 py-1.5 text-sm text-sky-700" className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-3 py-1.5 text-sm text-rose-700"
> >
{formatUserLabel(user)} {formatUserLabel(user)}
<button <button
type="button" type="button"
onClick={() => handleRemove(user.id)} onClick={() => handleRemove(user.id)}
className="text-xs text-sky-500 transition hover:text-sky-700" className="text-xs text-rose-500 transition hover:text-rose-700"
> >
移除 移除
</button> </button>
</span> </span>
)) ))
) : ( ) : (
<span className="text-sm text-zinc-400">尚未选择成员</span> <span className="text-sm text-zinc-400">尚未选择用户</span>
)} )}
</div> </div>
</div> </div>
...@@ -322,6 +347,300 @@ function formatPercent(value) { ...@@ -322,6 +347,300 @@ function formatPercent(value) {
return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`; return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`;
} }
function normalizeImportText(value) {
return String(value ?? '').trim();
}
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));
const normalizedExcelDate = formatDateInput(parsedExcelDate.toISOString());
if (normalizedExcelDate) {
return normalizedExcelDate;
}
}
const sanitizedValue = normalizedValue
.replace(/\s+/g, '')
.replace(/[.]/g, '-')
.replace(/[\/]/g, '-')
.replace(/年/g, '-')
.replace(/月/g, '-')
.replace(/日/g, '');
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')}`,
);
}
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 '';
}
function parsePercentInput(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const numericText = normalizedValue.endsWith('%')
? normalizedValue.slice(0, -1).trim()
: normalizedValue;
const numericValue = Number(numericText);
if (!Number.isFinite(numericValue)) {
return '';
}
return Math.max(0, Math.min(100, numericValue));
}
function parseOccurrenceFrequency(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const valueMap = {
高: '3',
中: '2',
低: '1',
3: '3',
2: '2',
1: '1',
};
return valueMap[normalizedValue] || normalizedValue;
}
function parseProjectStatus(value) {
const normalizedValue = normalizeImportText(value);
if (!normalizedValue) {
return '';
}
const valueMap = {
草稿: 'draft',
进行中: 'in_progress',
已完成: 'completed',
受阻: 'blocked',
已取消: 'cancelled',
draft: 'draft',
in_progress: 'in_progress',
completed: 'completed',
blocked: 'blocked',
cancelled: 'cancelled',
};
return valueMap[normalizedValue] || normalizedValue;
}
function parseBooleanInput(value) {
const normalizedValue = normalizeImportText(value).toLowerCase();
if (!normalizedValue) {
return false;
}
if (
['是', 'true', '1', 'y', 'yes', '有', '属于'].includes(normalizedValue)
) {
return true;
}
if (
['否', 'false', '0', 'n', 'no', '无', '不属于'].includes(normalizedValue)
) {
return false;
}
return false;
}
function buildImportRemarkSegments({
baseRemark,
plannedProgress,
}) {
const segments = [];
const normalizedRemark = normalizeImportText(baseRemark);
if (normalizedRemark) {
segments.push(normalizedRemark);
}
if (plannedProgress) {
segments.push(`导入时间进度:${plannedProgress}`);
}
return segments.join(';');
}
function normalizeImportWeeklyUpdates(row) {
const weekStartDate = parseLooseDateInput(row['周进度开始日期']);
const weekEndDate = parseLooseDateInput(row['周进度结束日期']);
const weeklySummary = normalizeImportText(row['周进度内容']);
const progressPercent = parsePercentInput(row['周进度进度']);
const blockers = normalizeImportText(row['周进度阻塞']);
const nextWeekPlan = normalizeImportText(row['周进度下周计划']);
const updatedAt = parseLooseDateInput(row['周进度记录日期']) || resolveCurrentDate();
if (!weeklySummary && !weekStartDate && !weekEndDate && !progressPercent && !blockers && !nextWeekPlan) {
return [];
}
return {
items: [
{
week_start_date: weekStartDate,
week_end_date: weekEndDate,
weekly_summary: weeklySummary,
progress_percent_after_update: progressPercent === '' ? '' : Number(progressPercent),
blockers,
next_week_plan: nextWeekPlan,
updated_at: updatedAt,
},
],
};
}
async function mapImportRowToTask(row) {
const projectName = normalizeImportText(row['任务名称'] || row['项目名称']);
const bigCategory = normalizeImportText(row['大类']);
const project = normalizeImportText(row['项目']);
const projectOwnerSource = normalizeImportText(row['主办人'] || row['项目负责人']);
const projectCollaboratorSource = normalizeImportText(row['协办人1'] || row['项目协作人']);
const projectOwner = await resolveImportUserReferenceList(projectOwnerSource, '主办人');
const projectCollaborator = await resolveImportUserReferenceList(
projectCollaboratorSource,
'协办人',
);
const taskSource = normalizeImportText(row['任务来源']);
const taskNature = normalizeImportText(row['任务性质']);
const occurrenceFrequency = parseOccurrenceFrequency(row['发生频率']);
const plannedStartDate = parseLooseDateInput(row['计划启动时间'] || row['计划开始日期']);
const plannedEndDate = parseLooseDateInput(row['计划完成时间'] || row['计划结束日期']);
const actualStartDate = parseLooseDateInput(row['实际开始日期']);
const actualFinishDate = parseLooseDateInput(row['实际完成日期']);
const projectStatus = parseProjectStatus(row['任务状态'] || row['项目状态']);
const completionPercent = parsePercentInput(row['任务进度'] || row['完成进度']);
const priority = normalizeImportText(row['优先级']);
const description = normalizeImportText(row['项目说明']);
const annualAuditPlan = parseBooleanInput(row['是否属于年度审计计划']);
const plannedProgress = normalizeImportText(row['时间进度']);
const remark = buildImportRemarkSegments({
baseRemark: row['备注'],
plannedProgress,
});
const delayReason = normalizeImportText(row['延期原因']);
const weeklyUpdates = normalizeImportWeeklyUpdates(row);
const metadata = {
project_name: projectName,
big_category: bigCategory,
project,
project_owner: projectOwner,
project_collaborator: projectCollaborator,
task_source: taskSource,
task_nature: taskNature,
occurrence_frequency: occurrenceFrequency,
planned_start_date: plannedStartDate,
planned_end_date: plannedEndDate,
actual_start_date: actualStartDate,
actual_finish_date: actualFinishDate,
project_status: projectStatus,
completion_percent: completionPercent === '' ? 0 : Number(completionPercent),
priority,
is_annual_audit_plan: annualAuditPlan,
description,
remark,
delay_reason: delayReason,
weekly_updates: weeklyUpdates,
};
return {
title: projectName || project || '未命名项目',
subject: 'pm_project_task',
metadata,
__display: {
project_owner: projectOwnerSource,
project_collaborator: projectCollaboratorSource,
},
};
}
async function buildImportPreviewRow(row, rowIndex) {
let payload = null;
let error = '';
try {
payload = await mapImportRowToTask(row);
buildTaskPayload({
project_name: payload.metadata.project_name,
big_category: payload.metadata.big_category,
project: payload.metadata.project,
project_owner: payload.metadata.project_owner,
project_collaborator: payload.metadata.project_collaborator,
task_source: payload.metadata.task_source,
task_nature: payload.metadata.task_nature,
occurrence_frequency: payload.metadata.occurrence_frequency,
planned_start_date: payload.metadata.planned_start_date,
planned_end_date: payload.metadata.planned_end_date,
actual_start_date: payload.metadata.actual_start_date,
actual_finish_date: payload.metadata.actual_finish_date,
description: payload.metadata.description,
remark: payload.metadata.remark,
priority: payload.metadata.priority,
is_annual_audit_plan: payload.metadata.is_annual_audit_plan,
project_status: payload.metadata.project_status,
completion_percent: payload.metadata.completion_percent,
delay_reason: payload.metadata.delay_reason,
weekly_updates: payload.metadata.weekly_updates,
});
} catch (requestError) {
error = requestError?.message || '数据格式错误';
}
return {
...(payload || {
title: normalizeImportText(row['任务名称'] || row['项目名称']) || '未命名项目',
subject: 'pm_project_task',
metadata: {
project_name: normalizeImportText(row['任务名称'] || row['项目名称']),
project_owner: [],
project_collaborator: [],
planned_start_date: parseLooseDateInput(row['计划启动时间'] || row['计划开始日期']),
planned_end_date: parseLooseDateInput(row['计划完成时间'] || row['计划结束日期']),
weekly_updates: normalizeImportWeeklyUpdates(row),
},
__display: {
project_owner: normalizeImportText(row['主办人'] || row['项目负责人']),
project_collaborator: normalizeImportText(row['协办人1'] || row['项目协作人']),
},
}),
__rowIndex: rowIndex,
error,
};
}
function getTaskTone(task) { function getTaskTone(task) {
if (task.resultStatus === 'completed_early') { if (task.resultStatus === 'completed_early') {
return { return {
...@@ -346,9 +665,9 @@ function getTaskTone(task) { ...@@ -346,9 +665,9 @@ function getTaskTone(task) {
} }
if (task.projectStatus === 'completed') { if (task.projectStatus === 'completed') {
return { return {
surface: 'bg-sky-50 border-sky-200', surface: 'bg-rose-50 border-rose-200',
accent: 'bg-sky-500', accent: 'bg-rose-500',
text: 'text-sky-700', text: 'text-rose-700',
}; };
} }
return { return {
...@@ -366,7 +685,7 @@ function getCalendarEventTone(task) { ...@@ -366,7 +685,7 @@ function getCalendarEventTone(task) {
return 'bg-rose-100 border-rose-300 text-rose-700'; return 'bg-rose-100 border-rose-300 text-rose-700';
} }
if (task.projectStatus === 'completed') { if (task.projectStatus === 'completed') {
return 'bg-sky-100 border-sky-300 text-sky-700'; return 'bg-rose-100 border-rose-300 text-rose-700';
} }
if (task.projectStatus === 'blocked') { if (task.projectStatus === 'blocked') {
return 'bg-amber-100 border-amber-300 text-amber-700'; return 'bg-amber-100 border-amber-300 text-amber-700';
...@@ -610,22 +929,89 @@ function buildMonthCalendarMatrix(anchorDate) { ...@@ -610,22 +929,89 @@ function buildMonthCalendarMatrix(anchorDate) {
return weeks; return weeks;
} }
function buildCalendarRangeRows(monthWeeks, tasks) {
return monthWeeks.map((week) => {
const weekStartKey = week[0]?.dateKey || '';
const weekEndKey = week[6]?.dateKey || '';
const segments = tasks
.filter(
(task) =>
task.plannedStartDate &&
task.plannedEndDate &&
task.plannedStartDate <= weekEndKey &&
task.plannedEndDate >= weekStartKey,
)
.sort((left, right) => {
if (left.plannedStartDate === right.plannedStartDate) {
if (left.plannedEndDate === right.plannedEndDate) {
return left.projectName.localeCompare(right.projectName);
}
return right.plannedEndDate.localeCompare(left.plannedEndDate);
}
return left.plannedStartDate.localeCompare(right.plannedStartDate);
})
.map((task) => {
const visibleStartKey =
task.plannedStartDate > weekStartKey ? task.plannedStartDate : weekStartKey;
const visibleEndKey =
task.plannedEndDate < weekEndKey ? task.plannedEndDate : weekEndKey;
const startColumn = week.findIndex((day) => day.dateKey === visibleStartKey) + 1;
const endColumn = week.findIndex((day) => day.dateKey === visibleEndKey) + 1;
return {
task,
startKey: visibleStartKey,
endKey: visibleEndKey,
startColumn,
span: Math.max(endColumn - startColumn + 1, 1),
};
})
.filter((segment) => segment.startColumn > 0);
const laneEndKeys = [];
const positionedSegments = segments.map((segment) => {
let laneIndex = laneEndKeys.findIndex((endKey) => segment.startKey > endKey);
if (laneIndex < 0) {
laneIndex = laneEndKeys.length;
laneEndKeys.push(segment.endKey);
} else {
laneEndKeys[laneIndex] = segment.endKey;
}
return {
...segment,
lane: laneIndex + 1,
};
});
return {
week,
segments: positionedSegments,
laneCount: Math.max(laneEndKeys.length, 1),
};
});
}
function buildTaskFormValues(task) { function buildTaskFormValues(task) {
const metadata = task?.metadata || {}; const metadata = task?.metadata || {};
return { return {
project_name: metadata.project_name || '', project_name: metadata.project_name || '',
project_code: metadata.project_code || '', big_category: metadata.big_category || '',
leader: project: metadata.project || '',
metadata.leader?._id || project_owner: Array.isArray(metadata.project_owner)
metadata.leader?.id || ? metadata.project_owner.map(resolveUserReferenceId).filter(Boolean)
metadata.leader?.username || : metadata.project_owner
metadata.leader || ? [resolveUserReferenceId(metadata.project_owner)].filter(Boolean)
'',
member_ids: Array.isArray(metadata.member_ids)
? metadata.member_ids
: metadata.member_ids
? [metadata.member_ids]
: [], : [],
project_collaborator: Array.isArray(metadata.project_collaborator)
? metadata.project_collaborator.map(resolveUserReferenceId).filter(Boolean)
: metadata.project_collaborator
? [resolveUserReferenceId(metadata.project_collaborator)].filter(Boolean)
: [],
task_source: metadata.task_source || '',
task_nature: metadata.task_nature || '',
occurrence_frequency: String(metadata.occurrence_frequency ?? '').trim(),
planned_start_date: formatDateInput(metadata.planned_start_date), planned_start_date: formatDateInput(metadata.planned_start_date),
planned_end_date: formatDateInput(metadata.planned_end_date), planned_end_date: formatDateInput(metadata.planned_end_date),
actual_start_date: formatDateInput(metadata.actual_start_date), actual_start_date: formatDateInput(metadata.actual_start_date),
...@@ -633,41 +1019,46 @@ function buildTaskFormValues(task) { ...@@ -633,41 +1019,46 @@ function buildTaskFormValues(task) {
description: metadata.description || '', description: metadata.description || '',
remark: metadata.remark || '', remark: metadata.remark || '',
priority: metadata.priority || '', priority: metadata.priority || '',
is_annual_audit_plan: Boolean(metadata.is_annual_audit_plan),
project_status: metadata.project_status || 'draft', project_status: metadata.project_status || 'draft',
completion_percent: metadata.completion_percent ?? 0, completion_percent: metadata.completion_percent ?? 0,
result_status: metadata.result_status || '', delay_reason: metadata.delay_reason || '',
is_overtime: Boolean(metadata.is_overtime), weekly_updates: normalizeWeeklyUpdatesValue(metadata.weekly_updates),
overtime_days: metadata.overtime_days ?? 0,
}; };
} }
function buildLocalTaskRecord(values, currentTask) { function buildWeeklyUpdateFormValues(update) {
return { return {
...(currentTask || {}), week_start_date: formatDateInput(update?.week_start_date),
_id: getTaskRecordId(currentTask) || `demo-task-${Date.now()}`, week_end_date: formatDateInput(update?.week_end_date),
title: values.project_name || currentTask?.title || '未命名项目', weekly_summary: update?.weekly_summary || '',
subject: currentTask?.subject || 'pm_project_task', progress_percent_after_update:
metadata: { update?.progress_percent_after_update ?? update?.progress ?? '',
project_name: values.project_name || '', blockers: update?.blockers || '',
project_code: values.project_code || '', next_week_plan: update?.next_week_plan || '',
leader: values.leader || '', updated_at: formatDateInput(update?.updated_at) || resolveCurrentDate(),
member_ids: Array.isArray(values.member_ids) ? values.member_ids : [],
planned_start_date: formatDateInput(values.planned_start_date),
planned_end_date: formatDateInput(values.planned_end_date),
actual_start_date: formatDateInput(values.actual_start_date),
actual_finish_date: formatDateInput(values.actual_finish_date),
description: values.description || '',
remark: values.remark || '',
priority: values.priority || '',
project_status: values.project_status || 'draft',
completion_percent: Number(values.completion_percent || 0),
result_status: values.result_status || '',
is_overtime: Boolean(values.is_overtime),
overtime_days: Number(values.overtime_days || 0),
},
}; };
} }
function formatWeeklyUpdateRange(update) {
const startDate = formatDateInput(update?.week_start_date);
const endDate = formatDateInput(update?.week_end_date);
if (startDate && endDate) {
return `${startDate} ~ ${endDate}`;
}
if (startDate) {
return startDate;
}
if (endDate) {
return endDate;
}
return '未填写周期';
}
function ToolbarCard({ children }) { function ToolbarCard({ children }) {
return ( return (
<div className="rounded-2xl border border-zinc-200/80 bg-white p-4 shadow-sm"> <div className="rounded-2xl border border-zinc-200/80 bg-white p-4 shadow-sm">
...@@ -683,7 +1074,7 @@ function StatCard({ label, value, hint, tone = 'default' }) { ...@@ -683,7 +1074,7 @@ function StatCard({ label, value, hint, tone = 'default' }) {
: tone === 'success' : tone === 'success'
? 'from-emerald-50 to-white border-emerald-200' ? 'from-emerald-50 to-white border-emerald-200'
: tone === 'primary' : tone === 'primary'
? 'from-sky-50 to-white border-sky-200' ? 'from-rose-50 to-white border-rose-200'
: 'from-zinc-50 to-white border-zinc-200'; : 'from-zinc-50 to-white border-zinc-200';
return ( return (
...@@ -695,7 +1086,7 @@ function StatCard({ label, value, hint, tone = 'default' }) { ...@@ -695,7 +1086,7 @@ function StatCard({ label, value, hint, tone = 'default' }) {
> >
<div className="text-sm font-medium text-zinc-500">{label}</div> <div className="text-sm font-medium text-zinc-500">{label}</div>
<div className="mt-3 text-3xl font-semibold text-zinc-900">{value}</div> <div className="mt-3 text-3xl font-semibold text-zinc-900">{value}</div>
<div className="mt-2 text-sm text-zinc-500">{hint}</div> {hint ? <div className="mt-2 text-sm text-zinc-500">{hint}</div> : null}
</div> </div>
); );
} }
...@@ -705,7 +1096,7 @@ function FilterSelect({ value, onChange, options, placeholder = '全部' }) { ...@@ -705,7 +1096,7 @@ function FilterSelect({ value, onChange, options, placeholder = '全部' }) {
<select <select
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
className="h-10 rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400" className="h-10 rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
> >
<option value="">{placeholder}</option> <option value="">{placeholder}</option>
{options.map((option) => ( {options.map((option) => (
...@@ -739,7 +1130,7 @@ function GanttLegend() { ...@@ -739,7 +1130,7 @@ function GanttLegend() {
{ label: '正常进行', className: 'bg-zinc-400' }, { label: '正常进行', className: 'bg-zinc-400' },
{ label: '提前完成', className: 'bg-emerald-500' }, { label: '提前完成', className: 'bg-emerald-500' },
{ label: '已超时', className: 'bg-rose-500' }, { label: '已超时', className: 'bg-rose-500' },
{ label: '已完成', className: 'bg-sky-500' }, { label: '已完成', className: 'bg-rose-500' },
{ label: '受阻', className: 'bg-amber-500' }, { label: '受阻', className: 'bg-amber-500' },
]; ];
...@@ -803,9 +1194,6 @@ function ProjectGanttBoard({ ...@@ -803,9 +1194,6 @@ function ProjectGanttBoard({
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<div className="text-lg font-semibold text-zinc-900">项目甘特图</div> <div className="text-lg font-semibold text-zinc-900">项目甘特图</div>
<div className="mt-1 text-sm text-zinc-500">
参考 `project_manager_new` 的时间轴结构,支持周、月、年视图切换,默认按月查看。
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <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"> <div className="inline-flex rounded-full border border-zinc-200 bg-zinc-100/80 p-1">
...@@ -875,7 +1263,7 @@ function ProjectGanttBoard({ ...@@ -875,7 +1263,7 @@ function ProjectGanttBoard({
key={column.key} key={column.key}
className={clsx( className={clsx(
'border-b border-r border-zinc-200 px-2 py-3 text-center', 'border-b border-r border-zinc-200 px-2 py-3 text-center',
column.isToday ? 'bg-sky-50 text-sky-700' : 'bg-zinc-50 text-zinc-500', column.isToday ? 'bg-rose-50 text-rose-700' : 'bg-zinc-50 text-zinc-500',
)} )}
> >
<div className="text-sm font-semibold leading-none">{column.primary}</div> <div className="text-sm font-semibold leading-none">{column.primary}</div>
...@@ -910,23 +1298,30 @@ function ProjectGanttBoard({ ...@@ -910,23 +1298,30 @@ function ProjectGanttBoard({
type="button" type="button"
onClick={() => onSelectTask(taskId)} onClick={() => onSelectTask(taskId)}
className={clsx( className={clsx(
'sticky left-0 z-[5] flex h-[118px] flex-col items-start justify-center border-b border-r border-zinc-200 bg-white px-4 py-4 text-left transition hover:bg-zinc-50', '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-sky-50' : '', selectedTaskId === taskId ? 'bg-rose-50' : '',
)} )}
> >
<span className="text-sm font-semibold text-zinc-900"> <div className="w-full">
{task.projectName} <div className="truncate text-sm font-semibold text-zinc-900">
</span> {task.projectName}
<span className="mt-1 text-xs text-zinc-500"> </div>
{task.projectCode} · {task.projectStatusLabel} <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] leading-4 text-zinc-500">
</span> <span className="truncate">
<span className={clsx('mt-2 text-xs font-medium', tone.text)}> {task.bigCategory || task.projectCategory || '未分类'}
{task.resultStatusLabel} </span>
</span> <span className="truncate">
负责人 {formatUserLabel(task.projectOwnerUser)}
</span>
<span className={clsx('font-medium', tone.text)}>
{task.projectStatusLabel} / {task.resultStatusLabel}
</span>
</div>
</div>
</button> </button>
<div <div
className="relative h-[118px] border-b border-zinc-200" className="relative h-[104px] border-b border-zinc-200"
style={{ gridColumn: `2 / span ${timelineMeta.columns.length}` }} style={{ gridColumn: `2 / span ${timelineMeta.columns.length}` }}
> >
<div <div
...@@ -935,62 +1330,69 @@ function ProjectGanttBoard({ ...@@ -935,62 +1330,69 @@ function ProjectGanttBoard({
gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`, gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
}} }}
> >
{timelineMeta.columns.map((column) => ( {timelineMeta.columns.map((column) => (
<div <div
key={`${taskId}-${column.key}`} key={`${taskId}-${column.key}`}
className={clsx( className={clsx(
'h-[118px] border-r border-zinc-200', 'h-[104px] border-r border-zinc-200',
column.isToday ? 'bg-sky-50/55' : 'bg-white', column.isToday ? 'bg-rose-50/60' : 'bg-white',
)} )}
/> />
))} ))}
</div> </div>
{hasVisibleBar ? ( {hasVisibleBar ? (
<div <div
className="absolute inset-0 grid" className="absolute inset-0 grid items-center"
style={{ style={{
gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`, gridTemplateColumns: `repeat(${timelineMeta.columns.length}, minmax(${columnMinWidth}px, 1fr))`,
gridTemplateRows: '1fr',
}} }}
> >
<button <div
type="button" className="flex h-full items-center"
onClick={() => {
onSelectTask(taskId);
onEditTask(task);
}}
className={clsx(
'relative flex items-center self-center rounded-[22px] border px-4 py-3 text-left shadow-sm transition hover:brightness-[0.98]',
tone.surface,
selectedTaskId === taskId ? 'ring-2 ring-sky-300' : 'ring-1 ring-transparent',
)}
style={{ style={{
gridColumn: `${startIndex + 1} / span ${span}`, gridColumn: `${startIndex + 1} / span ${span}`,
minHeight: viewType === GANTT_VIEW_TYPES.YEAR ? '64px' : '72px',
}} }}
> >
<div className={clsx('mr-3 h-5 w-1 rounded-full', tone.accent)} /> <button
<div className="min-w-0 flex-1 text-left"> type="button"
<div className="truncate text-[15px] font-semibold text-zinc-900"> onClick={() => {
{task.projectName} onSelectTask(taskId);
</div> onEditTask(task);
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-zinc-500"> }}
<span>{formatPercent(task.progressPercent)}</span>
<span>
{task.plannedStartDate} ~ {task.plannedEndDate}
</span>
</div>
</div>
<span
className={clsx( className={clsx(
'rounded-full px-2.5 py-1 text-xs font-medium', '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, tone.surface,
tone.text, selectedTaskId === taskId ? 'ring-2 ring-rose-300' : 'ring-1 ring-transparent',
)} )}
> style={{
{task.resultStatusLabel} height: viewType === GANTT_VIEW_TYPES.YEAR ? '56px' : '62px',
</span> }}
</button> >
<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> </div>
) : null} ) : null}
</div> </div>
...@@ -1022,23 +1424,14 @@ function ProjectCalendarBoard({ ...@@ -1022,23 +1424,14 @@ function ProjectCalendarBoard({
const currentUserTasks = useMemo( const currentUserTasks = useMemo(
() => () =>
tasks.filter((task) => currentUserId && task.memberIds.includes(currentUserId)), tasks.filter((task) => currentUserId && task.relatedUserIds.includes(currentUserId)),
[currentUserId, tasks], [currentUserId, tasks],
); );
const eventsByDate = useMemo(() => { const calendarRows = useMemo(
const map = new Map(); () => buildCalendarRangeRows(monthWeeks, currentUserTasks),
currentUserTasks.forEach((task) => { [currentUserTasks, monthWeeks],
enumerateDateRange(task.plannedStartDate, task.plannedEndDate).forEach( );
(dateKey) => {
const current = map.get(dateKey) || [];
current.push(task);
map.set(dateKey, current);
},
);
});
return map;
}, [currentUserTasks]);
const title = useMemo(() => { const title = useMemo(() => {
const [year, month] = monthAnchor.split('-'); const [year, month] = monthAnchor.split('-');
...@@ -1056,9 +1449,6 @@ function ProjectCalendarBoard({ ...@@ -1056,9 +1449,6 @@ function ProjectCalendarBoard({
<div className="flex items-center justify-between border-b border-zinc-200 px-5 py-4"> <div className="flex items-center justify-between border-b border-zinc-200 px-5 py-4">
<div> <div>
<div className="text-lg font-semibold text-zinc-900">个人项目日历</div> <div className="text-lg font-semibold text-zinc-900">个人项目日历</div>
<div className="mt-1 text-sm text-zinc-500">
当前用户只看 `member_ids` 包含自己的项目,和甘特图共享同一条项目记录。
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
...@@ -1089,53 +1479,96 @@ function ProjectCalendarBoard({ ...@@ -1089,53 +1479,96 @@ function ProjectCalendarBoard({
))} ))}
</div> </div>
<div className="grid grid-cols-7"> <div className="divide-y divide-zinc-200">
{monthWeeks.flat().map((cell) => { {calendarRows.map((row) => (
const events = eventsByDate.get(cell.dateKey) || []; <div key={row.week[0]?.dateKey} className="overflow-hidden">
const isToday = cell.dateKey === resolveCurrentDate(); <div className="grid grid-cols-7">
{row.week.map((cell) => {
const isToday = cell.dateKey === resolveCurrentDate();
return (
<div
key={cell.dateKey}
className={clsx(
'min-h-[58px] border-r border-zinc-200 px-3 py-3 last:border-r-0',
cell.isCurrentMonth ? 'bg-white' : 'bg-zinc-50/70',
)}
>
<div
className={clsx(
'flex items-center justify-between text-xs',
isToday ? 'font-semibold text-rose-700' : 'text-zinc-500',
)}
>
<span>{cell.dateKey.slice(8)}</span>
{isToday ? <span>今天</span> : null}
</div>
</div>
);
})}
</div>
return (
<div <div
key={cell.dateKey} className="relative border-t border-zinc-200 bg-white px-2 py-3"
className={clsx( style={{
'min-h-[148px] border-b border-r border-zinc-200 p-3', minHeight: `${row.laneCount * 36 + 20}px`,
cell.isCurrentMonth ? 'bg-white' : 'bg-zinc-50/60', }}
)}
> >
<div <div className="grid grid-cols-7 gap-x-0">
className={clsx( {row.week.map((cell) => (
'mb-3 flex items-center justify-between text-xs', <div
isToday ? 'font-semibold text-sky-700' : 'text-zinc-500', key={`${cell.dateKey}-lane-guide`}
)}
>
<span>{cell.dateKey.slice(8)}</span>
{events.length > 0 ? <span>{events.length} 项</span> : null}
</div>
<div className="space-y-2">
{events.slice(0, 3).map((task) => (
<button
key={`${cell.dateKey}-${getTaskRecordId(task)}`}
type="button"
onClick={() => onOpenTask(task)}
className={clsx( className={clsx(
'block w-full rounded-xl border px-3 py-2 text-left text-xs shadow-sm transition hover:brightness-[0.98]', 'h-full min-h-[36px] border-r border-dashed border-zinc-100 last:border-r-0',
getCalendarEventTone(task), cell.isCurrentMonth ? 'bg-white' : 'bg-zinc-50/40',
)} )}
> />
<div className="truncate font-semibold">{task.projectName}</div>
<div className="mt-1 truncate opacity-80">
{task.projectStatusLabel} · {formatPercent(task.progressPercent)}
</div>
</button>
))} ))}
{events.length > 3 ? (
<div className="text-xs text-zinc-400">还有 {events.length - 3} 项</div>
) : null}
</div> </div>
{row.segments.length > 0 ? (
<div
className="absolute inset-x-2 top-3 grid gap-y-2"
style={{
gridTemplateColumns: 'repeat(7, minmax(0, 1fr))',
gridTemplateRows: `repeat(${row.laneCount}, minmax(28px, 28px))`,
}}
>
{row.segments.map((segment) => {
const taskId = getTaskRecordId(segment.task);
return (
<button
key={`${row.week[0]?.dateKey}-${taskId}`}
type="button"
onClick={() => onOpenTask(segment.task)}
className={clsx(
'flex min-w-0 items-center rounded-full border px-3 text-left text-xs shadow-sm transition hover:brightness-[0.98]',
getCalendarEventTone(segment.task),
)}
style={{
gridColumn: `${segment.startColumn} / span ${segment.span}`,
gridRow: String(segment.lane),
}}
>
<span className="min-w-0 truncate font-semibold">
{segment.task.projectName}
</span>
{segment.span >= 2 ? (
<span className="ml-2 shrink-0 opacity-80">
{segment.task.projectStatusLabel} · {formatPercent(segment.task.progressPercent)}
</span>
) : null}
</button>
);
})}
</div>
) : (
<div className="flex min-h-[36px] items-center px-2 text-xs text-zinc-400">
本周暂无项目安排
</div>
)}
</div> </div>
); </div>
})} ))}
</div> </div>
</div> </div>
); );
...@@ -1146,9 +1579,6 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) { ...@@ -1146,9 +1579,6 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
<div className="rounded-2xl border border-zinc-200 bg-white shadow-sm"> <div className="rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-200 px-5 py-4"> <div className="border-b border-zinc-200 px-5 py-4">
<div className="text-lg font-semibold text-zinc-900">项目列表</div> <div className="text-lg font-semibold text-zinc-900">项目列表</div>
<div className="mt-1 text-sm text-zinc-500">
统一查看当前项目状态、共享进度和超时情况。
</div>
</div> </div>
<div className="divide-y divide-zinc-100"> <div className="divide-y divide-zinc-100">
{tasks.length > 0 ? ( {tasks.length > 0 ? (
...@@ -1159,7 +1589,7 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) { ...@@ -1159,7 +1589,7 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
key={taskId} key={taskId}
className={clsx( className={clsx(
'flex items-center justify-between gap-4 px-5 py-4 transition hover:bg-zinc-50', 'flex items-center justify-between gap-4 px-5 py-4 transition hover:bg-zinc-50',
selectedTaskId === taskId ? 'bg-sky-50' : '', selectedTaskId === taskId ? 'bg-rose-50' : '',
)} )}
> >
<button <button
...@@ -1171,8 +1601,18 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) { ...@@ -1171,8 +1601,18 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
{task.projectName} {task.projectName}
</div> </div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
{task.projectCode} · {task.projectStatusLabel} · {task.resultStatusLabel} {task.bigCategory || '未分类'} · 负责人 {formatUserLabel(task.projectOwnerUser)} ·{' '}
{task.projectStatusLabel}
</div> </div>
{task.latestWeeklySummary ? (
<div className="mt-1 truncate text-xs text-zinc-400">
最近周进展:{task.latestWeeklySummary}
</div>
) : task.delayReason ? (
<div className="mt-1 truncate text-xs text-rose-500">
延期原因:{task.delayReason}
</div>
) : null}
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-right"> <div className="text-right">
...@@ -1180,7 +1620,9 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) { ...@@ -1180,7 +1620,9 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
{formatPercent(task.progressPercent)} {formatPercent(task.progressPercent)}
</div> </div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
{task.isOvertime ? `超时 ${task.overtimeDays} 天` : '正常'} {task.isOvertime
? `超时 ${task.overtimeDays} 天`
: `周进度 ${task.weeklyUpdateCount} 条`}
</div> </div>
</div> </div>
<button <button
...@@ -1204,6 +1646,276 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) { ...@@ -1204,6 +1646,276 @@ function ProjectListPanel({ tasks, selectedTaskId, onSelectTask, onEditTask }) {
); );
} }
function WeeklyUpdateEditorDialog({
open,
value,
editing,
error,
onChange,
onCancel,
onConfirm,
}) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-950/50 px-4 py-6">
<div className="w-full max-w-3xl rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.32)]">
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5">
<div>
<div className="text-lg font-semibold text-zinc-900">
{editing ? '编辑周进度' : '新增周进度'}
</div>
</div>
<button
type="button"
onClick={onCancel}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div>
<div className="grid gap-4 px-6 py-5 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">本周开始日期</span>
<input
type="date"
value={value.week_start_date}
onChange={(event) => onChange('week_start_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">本周结束日期</span>
<input
type="date"
value={value.week_end_date}
onChange={(event) => onChange('week_end_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600 md:col-span-2">
<span className="font-medium">本周完成内容</span>
<textarea
value={value.weekly_summary}
onChange={(event) => onChange('weekly_summary', event.target.value)}
className="min-h-[120px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">更新后累计进度</span>
<input
type="number"
min="0"
max="100"
value={value.progress_percent_after_update}
onChange={(event) =>
onChange('progress_percent_after_update', event.target.value)
}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">记录日期</span>
<input
type="date"
value={value.updated_at}
onChange={(event) => onChange('updated_at', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">当前阻塞事项</span>
<textarea
value={value.blockers}
onChange={(event) => onChange('blockers', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">下周计划</span>
<textarea
value={value.next_week_plan}
onChange={(event) => onChange('next_week_plan', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
</div>
{error ? (
<div className="px-6 pb-2 text-sm text-rose-600">{error}</div>
) : null}
<div className="flex items-center justify-end gap-3 border-t border-zinc-100 px-6 py-5">
<button
type="button"
onClick={onCancel}
className="rounded-xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-600 transition hover:bg-zinc-100"
>
取消
</button>
<button
type="button"
onClick={onConfirm}
className="rounded-xl bg-[var(--pm-brand)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--pm-brand-strong)]"
>
保存周进度
</button>
</div>
</div>
</div>
);
}
function ProjectImportDialog({
open,
loading,
error,
previewRows,
fileName,
successRows,
onClose,
onDownloadTemplate,
onFileChange,
onImport,
}) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/55 px-4 py-6">
<div className="max-h-[92vh] w-full max-w-5xl overflow-hidden rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.32)]">
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5">
<div>
<div className="text-xl font-semibold text-zinc-900">Excel 批量导入</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div>
<div className="space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onDownloadTemplate}
className="rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100"
>
下载模板
</button>
<label className="inline-flex cursor-pointer items-center rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100">
选择 Excel 文件
<input
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={onFileChange}
/>
</label>
<div className="text-sm text-zinc-500">
{fileName ? `文件:${fileName} · ` : ''}
识别到 {previewRows.length} 行,可导入 {successRows.length} 行
</div>
</div>
{error ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{error}
</div>
) : null}
<div className="max-h-[50vh] overflow-auto rounded-2xl border border-zinc-200">
<table className="min-w-full divide-y divide-zinc-100 text-sm">
<thead className="sticky top-0 bg-zinc-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-zinc-500">#</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">项目名称</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">负责人</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">协作人</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">计划时间</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">周进度</th>
<th className="px-4 py-3 text-left font-medium text-zinc-500">结果</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 bg-white">
{previewRows.length > 0 ? (
previewRows.map((row, index) => (
<tr key={`${row.__rowIndex}-${index}`}>
<td className="px-4 py-3 text-zinc-500">{row.__rowIndex}</td>
<td className="px-4 py-3 font-medium text-zinc-900">
{row.metadata?.project_name || '未命名项目'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.__display?.project_owner || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.__display?.project_collaborator || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.metadata?.planned_start_date || '-'} ~ {row.metadata?.planned_end_date || '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{Array.isArray(row.metadata?.weekly_updates?.items) &&
row.metadata.weekly_updates.items.length > 0
? `${row.metadata.weekly_updates.items.length} 条`
: '-'}
</td>
<td className="px-4 py-3 text-zinc-600">
{row.error ? (
<span className="text-rose-600">{row.error}</span>
) : (
<span className="text-emerald-600">可导入</span>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-zinc-400">
请选择 Excel 文件后查看预览
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-end gap-3 border-t border-zinc-100 pt-4">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-600 transition hover:bg-zinc-100"
>
取消
</button>
<button
type="button"
onClick={onImport}
disabled={loading || successRows.length === 0}
className="rounded-xl bg-[var(--pm-brand)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--pm-brand-strong)] disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? '导入中...' : `确认导入 ${successRows.length} 条`}
</button>
</div>
</div>
</div>
</div>
);
}
function ProjectTaskDialog({ function ProjectTaskDialog({
open, open,
task, task,
...@@ -1211,14 +1923,25 @@ function ProjectTaskDialog({ ...@@ -1211,14 +1923,25 @@ function ProjectTaskDialog({
submitError, submitError,
onClose, onClose,
onSubmit, onSubmit,
fieldOptions,
}) { }) {
const [formValues, setFormValues] = useState(buildTaskFormValues(null)); const [formValues, setFormValues] = useState(buildTaskFormValues(null));
const [weeklyUpdateDialogOpen, setWeeklyUpdateDialogOpen] = useState(false);
const [editingWeeklyUpdateIndex, setEditingWeeklyUpdateIndex] = useState(-1);
const [weeklyUpdateDraft, setWeeklyUpdateDraft] = useState(
buildWeeklyUpdateFormValues(null),
);
const [weeklyUpdateError, setWeeklyUpdateError] = useState('');
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return; return;
} }
setFormValues(buildTaskFormValues(task)); setFormValues(buildTaskFormValues(task));
setWeeklyUpdateDialogOpen(false);
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
setWeeklyUpdateError('');
}, [open, task]); }, [open, task]);
if (!open) { if (!open) {
...@@ -1232,72 +1955,277 @@ function ProjectTaskDialog({ ...@@ -1232,72 +1955,277 @@ function ProjectTaskDialog({
})); }));
}; };
const handleStartProject = () => {
onSubmit({
...formValues,
project_status: 'in_progress',
actual_start_date: formValues.actual_start_date || resolveCurrentDate(),
});
};
const handleCompleteProject = () => {
onSubmit({
...formValues,
project_status: 'completed',
actual_start_date: formValues.actual_start_date || resolveCurrentDate(),
actual_finish_date: formValues.actual_finish_date || resolveCurrentDate(),
completion_percent:
Number(formValues.completion_percent || 0) >= 100
? formValues.completion_percent
: 100,
});
};
const openCreateWeeklyUpdate = () => {
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(
buildWeeklyUpdateFormValues({
progress_percent_after_update: formValues.completion_percent,
}),
);
setWeeklyUpdateError('');
setWeeklyUpdateDialogOpen(true);
};
const openEditWeeklyUpdate = (update, index) => {
setEditingWeeklyUpdateIndex(index);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(update));
setWeeklyUpdateError('');
setWeeklyUpdateDialogOpen(true);
};
const handleWeeklyUpdateFieldChange = (field, value) => {
setWeeklyUpdateDraft((current) => ({
...current,
[field]: value,
}));
};
const handleWeeklyUpdateConfirm = () => {
const weeklySummary = String(weeklyUpdateDraft.weekly_summary || '').trim();
if (!weeklySummary) {
setWeeklyUpdateError('请填写本周完成内容');
return;
}
const weekStartDate = formatDateInput(weeklyUpdateDraft.week_start_date);
const weekEndDate = formatDateInput(weeklyUpdateDraft.week_end_date);
if (weekStartDate && weekEndDate && weekStartDate > weekEndDate) {
setWeeklyUpdateError('本周结束日期不能早于本周开始日期');
return;
}
const nextEntry = {
week_start_date: weekStartDate,
week_end_date: weekEndDate,
weekly_summary: weeklySummary,
progress_percent_after_update:
weeklyUpdateDraft.progress_percent_after_update === ''
? ''
: Number(weeklyUpdateDraft.progress_percent_after_update),
blockers: String(weeklyUpdateDraft.blockers || '').trim(),
next_week_plan: String(weeklyUpdateDraft.next_week_plan || '').trim(),
updated_at: formatDateInput(weeklyUpdateDraft.updated_at) || resolveCurrentDate(),
};
setFormValues((current) => {
const currentUpdates = Array.isArray(current.weekly_updates)
? [...current.weekly_updates]
: [];
if (
editingWeeklyUpdateIndex >= 0 &&
editingWeeklyUpdateIndex < currentUpdates.length
) {
currentUpdates[editingWeeklyUpdateIndex] = nextEntry;
} else {
currentUpdates.push(nextEntry);
}
const sortedUpdates = sortWeeklyUpdates(currentUpdates);
return {
...current,
completion_percent:
nextEntry.progress_percent_after_update === ''
? current.completion_percent
: nextEntry.progress_percent_after_update,
weekly_updates: sortedUpdates,
};
});
setWeeklyUpdateError('');
setWeeklyUpdateDialogOpen(false);
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
};
const handleWeeklyUpdateDelete = (index) => {
setFormValues((current) => ({
...current,
weekly_updates: current.weekly_updates.filter((_, itemIndex) => itemIndex !== index),
}));
};
const handleWeeklyUpdateCancel = () => {
setWeeklyUpdateDialogOpen(false);
setEditingWeeklyUpdateIndex(-1);
setWeeklyUpdateDraft(buildWeeklyUpdateFormValues(null));
setWeeklyUpdateError('');
};
const weeklyUpdates = Array.isArray(formValues.weekly_updates)
? formValues.weekly_updates
: [];
const priorityOptions = fieldOptions?.priorityOptions || [];
const projectStatusOptions =
fieldOptions?.projectStatusOptions?.length > 0
? fieldOptions.projectStatusOptions
: PROJECT_STATUS_OPTIONS;
const occurrenceFrequencyOptions =
fieldOptions?.occurrenceFrequencyOptions?.length > 0
? fieldOptions.occurrenceFrequencyOptions
: OCCURRENCE_FREQUENCY_OPTIONS;
const hasPersistedTask = Boolean(getTaskRecordId(task));
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6"> <>
<div className="max-h-[92vh] w-full max-w-5xl overflow-hidden rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.28)]"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6">
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5"> <div className="max-h-[92vh] w-full max-w-5xl overflow-hidden rounded-[28px] bg-white shadow-[0_32px_100px_rgba(15,23,42,0.28)]">
<div> <div className="flex items-center justify-between border-b border-zinc-200 px-6 py-5">
<div className="text-xl font-semibold text-zinc-900"> <div>
{task ? '编辑项目' : '新建项目'} <div className="text-xl font-semibold text-zinc-900">
{task ? '编辑项目' : '新建项目'}
</div>
</div> </div>
<div className="mt-1 text-sm text-zinc-500"> <div className="flex items-center gap-2">
表单已从页面底部改为弹框,便于在甘特图和列表中随时维护项目。 {hasPersistedTask && canStartProject(formValues.project_status) ? (
<button
type="button"
onClick={handleStartProject}
disabled={submitting}
className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100"
>
{submitting ? '启动中...' : '启动项目'}
</button>
) : null}
{hasPersistedTask && canCompleteProject(formValues.project_status) ? (
<button
type="button"
onClick={handleCompleteProject}
disabled={submitting}
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-100"
>
{submitting ? '完成中...' : '完成项目'}
</button>
) : null}
<button
type="button"
onClick={onClose}
disabled={submitting}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div> </div>
</div> </div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-zinc-200 px-3 py-2 text-sm text-zinc-500 transition hover:bg-zinc-100"
>
关闭
</button>
</div>
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
onSubmit(formValues); onSubmit(formValues);
}} }}
className="max-h-[calc(92vh-84px)] overflow-auto px-6 py-5" className="max-h-[calc(92vh-84px)] overflow-auto px-6 py-5"
> >
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-600"> <label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目名称</span> <span className="font-medium">项目名称</span>
<input <input
value={formValues.project_name} value={formValues.project_name}
onChange={(event) => updateField('project_name', event.target.value)} onChange={(event) => updateField('project_name', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
required required
/> />
</label> </label>
<label className="space-y-2 text-sm text-zinc-600"> <label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目编号</span> <span className="font-medium">大类</span>
<input
value={formValues.big_category}
onChange={(event) => updateField('big_category', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目</span>
<input
value={formValues.project}
onChange={(event) => updateField('project', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目负责人</span>
<UserMultiSearchSelect
value={formValues.project_owner}
onChange={(value) => updateField('project_owner', value)}
placeholder="远程搜索项目负责人"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目协作人</span>
<UserMultiSearchSelect
value={formValues.project_collaborator}
onChange={(value) => updateField('project_collaborator', value)}
placeholder="远程搜索项目协作人"
/>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">任务来源</span>
<input <input
value={formValues.project_code} value={formValues.task_source}
onChange={(event) => updateField('project_code', event.target.value)} onChange={(event) => updateField('task_source', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/> />
</label> </label>
<label className="space-y-2 text-sm text-zinc-600"> <label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">下发领导</span> <span className="font-medium">任务性质</span>
<UserSearchSelect <input
value={formValues.leader} value={formValues.task_nature}
onChange={(value) => updateField('leader', value)} onChange={(event) => updateField('task_nature', event.target.value)}
placeholder="远程搜索领导用户" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/> />
</label> </label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">发生频率</span>
<select
value={formValues.occurrence_frequency}
onChange={(event) => updateField('occurrence_frequency', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-rose-300"
>
<option value="">请选择发生频率</option>
{occurrenceFrequencyOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-zinc-600"> <label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">优先级</span> <span className="font-medium">优先级</span>
<select <select
value={formValues.priority} value={formValues.priority}
onChange={(event) => updateField('priority', event.target.value)} onChange={(event) => updateField('priority', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-rose-300"
> >
<option value="">请选择优先级</option> <option value="">请选择优先级</option>
{PRIORITY_OPTIONS.map((option) => ( {priorityOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
...@@ -1305,13 +2233,32 @@ function ProjectTaskDialog({ ...@@ -1305,13 +2233,32 @@ function ProjectTaskDialog({
</select> </select>
</label> </label>
<div className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">是否属于年度审计计划</span>
<div className="rounded-2xl border border-zinc-200 bg-white px-3 py-3">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={formValues.is_annual_audit_plan}
onChange={(event) =>
updateField('is_annual_audit_plan', event.target.checked)
}
className="mt-0.5 h-4 w-4 rounded border-zinc-300 text-rose-600 focus:ring-rose-500"
/>
<div>
<div className="text-sm font-medium text-zinc-900">属于年度审计计划</div>
</div>
</label>
</div>
</div>
<label className="space-y-2 text-sm text-zinc-600"> <label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">计划开始日期</span> <span className="font-medium">计划开始日期</span>
<input <input
type="date" type="date"
value={formValues.planned_start_date} value={formValues.planned_start_date}
onChange={(event) => updateField('planned_start_date', event.target.value)} onChange={(event) => updateField('planned_start_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
required required
/> />
</label> </label>
...@@ -1322,7 +2269,7 @@ function ProjectTaskDialog({ ...@@ -1322,7 +2269,7 @@ function ProjectTaskDialog({
type="date" type="date"
value={formValues.planned_end_date} value={formValues.planned_end_date}
onChange={(event) => updateField('planned_end_date', event.target.value)} onChange={(event) => updateField('planned_end_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
required required
/> />
</label> </label>
...@@ -1333,7 +2280,7 @@ function ProjectTaskDialog({ ...@@ -1333,7 +2280,7 @@ function ProjectTaskDialog({
type="date" type="date"
value={formValues.actual_start_date} value={formValues.actual_start_date}
onChange={(event) => updateField('actual_start_date', event.target.value)} onChange={(event) => updateField('actual_start_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/> />
</label> </label>
...@@ -1343,51 +2290,29 @@ function ProjectTaskDialog({ ...@@ -1343,51 +2290,29 @@ function ProjectTaskDialog({
type="date" type="date"
value={formValues.actual_finish_date} value={formValues.actual_finish_date}
onChange={(event) => updateField('actual_finish_date', event.target.value)} onChange={(event) => updateField('actual_finish_date', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/> />
</label> </label>
</div> </div>
<div className="mt-5 space-y-3"> <div className="mt-5 grid gap-4 md:grid-cols-2">
<div className="text-sm font-medium text-zinc-700">参与成员</div>
<UserMultiSearchSelect
value={formValues.member_ids}
onChange={(nextValue) => updateField('member_ids', nextValue)}
placeholder="远程搜索并选择参与成员"
/>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-3">
<label className="space-y-2 text-sm text-zinc-600"> <label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">项目状态</span> <span className="font-medium">项目状态</span>
<select <select
value={formValues.project_status} value={formValues.project_status}
onChange={(event) => updateField('project_status', event.target.value)} onChange={(event) => updateField('project_status', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-rose-300"
required required
> >
{PROJECT_STATUS_OPTIONS.map((option) => ( {projectStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">结果状态</span>
<select
value={formValues.result_status}
onChange={(event) => updateField('result_status', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 outline-none transition focus:border-sky-400"
>
<option value="">自动判定 / 手动补充</option>
{RESULT_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
))} ))}
</select> </select>
<div className="text-xs text-zinc-400">
工作流:草稿 {'->'} 进行中 {'->'} 已完成
</div>
</label> </label>
<label className="space-y-2 text-sm text-zinc-600"> <label className="space-y-2 text-sm text-zinc-600">
...@@ -1398,7 +2323,7 @@ function ProjectTaskDialog({ ...@@ -1398,7 +2323,7 @@ function ProjectTaskDialog({
max="100" max="100"
value={formValues.completion_percent} value={formValues.completion_percent}
onChange={(event) => updateField('completion_percent', event.target.value)} onChange={(event) => updateField('completion_percent', event.target.value)}
className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-sky-400" className="h-11 w-full rounded-xl border border-zinc-200 px-3 outline-none transition focus:border-rose-300"
/> />
</label> </label>
</div> </div>
...@@ -1409,7 +2334,7 @@ function ProjectTaskDialog({ ...@@ -1409,7 +2334,7 @@ function ProjectTaskDialog({
<textarea <textarea
value={formValues.description} value={formValues.description}
onChange={(event) => updateField('description', event.target.value)} onChange={(event) => updateField('description', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-sky-400" className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/> />
</label> </label>
...@@ -1418,11 +2343,94 @@ function ProjectTaskDialog({ ...@@ -1418,11 +2343,94 @@ function ProjectTaskDialog({
<textarea <textarea
value={formValues.remark} value={formValues.remark}
onChange={(event) => updateField('remark', event.target.value)} onChange={(event) => updateField('remark', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-sky-400" className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/>
</label>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm text-zinc-600">
<span className="font-medium">延期原因</span>
<textarea
value={formValues.delay_reason}
onChange={(event) => updateField('delay_reason', event.target.value)}
className="min-h-[110px] w-full rounded-2xl border border-zinc-200 px-3 py-3 outline-none transition focus:border-rose-300"
/> />
</label> </label>
</div> </div>
<div className="mt-5 rounded-2xl border border-zinc-200/90 bg-zinc-50/60 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="text-sm font-medium text-zinc-900">周进度记录</div>
<button
type="button"
onClick={openCreateWeeklyUpdate}
className="rounded-xl border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100"
>
新增本周进度
</button>
</div>
<div className="mt-4 space-y-3">
{weeklyUpdates.length > 0 ? (
weeklyUpdates.map((update, index) => (
<div
key={`${update.week_start_date || 'week'}-${index}`}
className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">
{formatWeeklyUpdateRange(update)}
</div>
<div className="mt-1 text-xs text-zinc-500">
累计进度{' '}
{update.progress_percent_after_update === ''
? '未填写'
: `${update.progress_percent_after_update}%`}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openEditWeeklyUpdate(update, index)}
className="rounded-lg border border-zinc-200 px-3 py-1.5 text-xs text-zinc-600 transition hover:bg-zinc-100"
>
编辑
</button>
<button
type="button"
onClick={() => handleWeeklyUpdateDelete(index)}
className="rounded-lg border border-rose-200 px-3 py-1.5 text-xs text-rose-600 transition hover:bg-rose-50"
>
删除
</button>
</div>
</div>
<div className="mt-3 text-sm text-zinc-700">{update.weekly_summary}</div>
{update.blockers ? (
<div className="mt-3 text-xs text-amber-700">
阻塞事项:{update.blockers}
</div>
) : null}
{update.next_week_plan ? (
<div className="mt-2 text-xs text-zinc-500">
下周计划:{update.next_week_plan}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-400">
暂无周进度记录
</div>
)}
</div>
</div>
{submitError ? ( {submitError ? (
<div className="mt-5 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600"> <div className="mt-5 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{submitError} {submitError}
...@@ -1452,9 +2460,20 @@ function ProjectTaskDialog({ ...@@ -1452,9 +2460,20 @@ function ProjectTaskDialog({
</button> </button>
</div> </div>
</div> </div>
</form> </form>
</div>
</div> </div>
</div>
<WeeklyUpdateEditorDialog
open={weeklyUpdateDialogOpen}
value={weeklyUpdateDraft}
editing={editingWeeklyUpdateIndex >= 0}
error={weeklyUpdateError}
onChange={handleWeeklyUpdateFieldChange}
onCancel={handleWeeklyUpdateCancel}
onConfirm={handleWeeklyUpdateConfirm}
/>
</>
); );
} }
...@@ -1463,12 +2482,11 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1463,12 +2482,11 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
keyword: '', keyword: '',
status: '', status: '',
memberId: '', ownerId: '',
leaderId: '', collaboratorId: '',
}); });
const [activeTab, setActiveTab] = useState('gantt'); const [activeTab, setActiveTab] = useState('gantt');
const [monthAnchor, setMonthAnchor] = useState(resolveCurrentDate().slice(0, 7)); const [monthAnchor, setMonthAnchor] = useState(resolveCurrentDate().slice(0, 7));
...@@ -1477,6 +2495,16 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1477,6 +2495,16 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
const [editingTask, setEditingTask] = useState(null); const [editingTask, setEditingTask] = useState(null);
const [dialogSubmitting, setDialogSubmitting] = useState(false); const [dialogSubmitting, setDialogSubmitting] = useState(false);
const [dialogError, setDialogError] = useState(''); const [dialogError, setDialogError] = useState('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importPreviewRows, setImportPreviewRows] = useState([]);
const [importError, setImportError] = useState('');
const [importLoading, setImportLoading] = useState(false);
const [importFileName, setImportFileName] = useState('');
const [fieldOptions, setFieldOptions] = useState({
projectStatusOptions: PROJECT_STATUS_OPTIONS,
priorityOptions: [],
occurrenceFrequencyOptions: OCCURRENCE_FREQUENCY_OPTIONS,
});
const loadWorkspace = async () => { const loadWorkspace = async () => {
setLoading(true); setLoading(true);
...@@ -1488,18 +2516,13 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1488,18 +2516,13 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
new Set( new Set(
taskRecords.flatMap((task) => { taskRecords.flatMap((task) => {
const metadata = task?.metadata || {}; const metadata = task?.metadata || {};
const memberIds = Array.isArray(metadata.member_ids) const projectOwnerIds = Array.isArray(metadata.project_owner)
? metadata.member_ids ? metadata.project_owner.map(resolveUserReferenceId)
: metadata.member_ids : [resolveUserReferenceId(metadata.project_owner)].filter(Boolean);
? [metadata.member_ids] const projectCollaboratorIds = Array.isArray(metadata.project_collaborator)
: []; ? metadata.project_collaborator.map(resolveUserReferenceId)
const leaderId = : [resolveUserReferenceId(metadata.project_collaborator)].filter(Boolean);
metadata.leader?._id || return [...projectOwnerIds, ...projectCollaboratorIds].filter(Boolean);
metadata.leader?.id ||
metadata.leader?.username ||
metadata.leader ||
'';
return [...memberIds, leaderId].filter(Boolean);
}), }),
), ),
); );
...@@ -1513,25 +2536,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1513,25 +2536,14 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
); );
setUsers(normalizedUsers); setUsers(normalizedUsers);
setTasks( setTasks(enrichedTasks);
enrichedTasks.length > 0
? enrichedTasks
: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)),
);
setIsDemoMode(enrichedTasks.length === 0);
if (enrichedTasks.length === 0) { if (enrichedTasks.length === 0) {
setError('当前未读取到项目记录,已展示演示数据,可继续预览页面交互。'); setError('当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。');
} }
} catch (requestError) { } catch (requestError) {
const demoUsers = [ setUsers([]);
{ id: 'yang_admin', username: 'yang_admin', displayName: 'yang' }, setTasks([]);
{ id: 'system', username: 'system', displayName: '系统' }, setError(requestError?.message || '加载项目管理工作台失败,请稍后重试。');
];
const userMap = buildUserMap(demoUsers);
setUsers(demoUsers);
setTasks(buildDemoTasks().map((task) => enrichProjectTask(task, userMap)));
setIsDemoMode(true);
setError(requestError?.message || '加载项目管理工作台失败,已切换为演示数据。');
} finally { } finally {
setLoading(false); setLoading(false);
} }
...@@ -1539,7 +2551,21 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1539,7 +2551,21 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
useEffect(() => { useEffect(() => {
loadWorkspace(); loadWorkspace();
}, [filters.status, filters.memberId, filters.leaderId]); }, [filters.status, filters.ownerId, filters.collaboratorId]);
useEffect(() => {
let cancelled = false;
loadProjectTaskFieldOptions().then((options) => {
if (!cancelled) {
setFieldOptions(options);
}
});
return () => {
cancelled = true;
};
}, []);
const userMap = useMemo(() => buildUserMap(users), [users]); const userMap = useMemo(() => buildUserMap(users), [users]);
...@@ -1557,7 +2583,10 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1557,7 +2583,10 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
if ( if (
keyword && keyword &&
!task.projectName.toLowerCase().includes(keyword) && !task.projectName.toLowerCase().includes(keyword) &&
!String(task.projectCode || '').toLowerCase().includes(keyword) !String(task.bigCategory || '').toLowerCase().includes(keyword) &&
!String(task.projectCategory || '').toLowerCase().includes(keyword) &&
!String(task.taskSource || '').toLowerCase().includes(keyword) &&
!String(task.taskNature || '').toLowerCase().includes(keyword)
) { ) {
return false; return false;
} }
...@@ -1606,34 +2635,98 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1606,34 +2635,98 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}; };
}, [visibleTasks]); }, [visibleTasks]);
const applyLocalTask = (values, currentTask) => { const persistTask = async (values, currentTask) => {
const nextTask = enrichProjectTask( await saveProjectTask(values, currentTask);
buildLocalTaskRecord(values, currentTask), await loadWorkspace();
userMap, };
);
setTasks((current) => { const handleImportTemplateDownload = () => {
const currentId = getTaskRecordId(currentTask); downloadProjectTaskImportTemplate();
if (!currentId) { };
return [nextTask, ...current];
}
return current.map((item) => const handleImportDialogClose = () => {
getTaskRecordId(item) === currentId ? nextTask : item, if (importLoading) {
); return;
}); }
setImportDialogOpen(false);
setImportPreviewRows([]);
setImportError('');
setImportFileName('');
};
const handleImportFileChange = async (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setImportLoading(true);
setImportError('');
setImportFileName(file.name);
setSelectedTaskId(getTaskRecordId(nextTask)); try {
const rows = await readProjectTaskImportRows(file);
const preview = await Promise.all(
rows.map((row, index) => buildImportPreviewRow(row, index + 2)),
);
setImportPreviewRows(preview);
setImportDialogOpen(true);
} catch (requestError) {
setImportError(requestError?.message || '解析 Excel 失败');
setImportPreviewRows([]);
setImportDialogOpen(true);
} finally {
setImportLoading(false);
event.target.value = '';
}
}; };
const persistTask = async (values, currentTask) => { const handleConfirmImport = async () => {
if (isDemoMode) { const validRows = importPreviewRows.filter((row) => !row.error);
applyLocalTask(values, currentTask); if (validRows.length === 0) {
setImportError('没有可导入的记录');
return; return;
} }
await saveProjectTask(values, currentTask); setImportLoading(true);
await loadWorkspace(); setImportError('');
try {
await importRecords(
'pm_project_task',
validRows.map((row) => ({
...buildImportTaskPayload({
project_name: row.metadata.project_name,
big_category: row.metadata.big_category,
project: row.metadata.project,
project_owner: row.metadata.project_owner,
project_collaborator: row.metadata.project_collaborator,
task_source: row.metadata.task_source,
task_nature: row.metadata.task_nature,
occurrence_frequency: row.metadata.occurrence_frequency,
planned_start_date: row.metadata.planned_start_date,
planned_end_date: row.metadata.planned_end_date,
actual_start_date: row.metadata.actual_start_date,
actual_finish_date: row.metadata.actual_finish_date,
description: row.metadata.description,
remark: row.metadata.remark,
priority: row.metadata.priority,
is_annual_audit_plan: row.metadata.is_annual_audit_plan,
project_status: row.metadata.project_status,
completion_percent: row.metadata.completion_percent,
delay_reason: row.metadata.delay_reason,
weekly_updates: row.metadata.weekly_updates,
}),
})),
);
await loadWorkspace();
handleImportDialogClose();
} catch (requestError) {
setImportError(requestError?.message || '导入失败,请稍后重试');
} finally {
setImportLoading(false);
}
}; };
const openCreateDialog = () => { const openCreateDialog = () => {
...@@ -1669,24 +2762,33 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1669,24 +2762,33 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
openEditDialog(task); openEditDialog(task);
}; };
const hasVisibleTasks = visibleTasks.length > 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<ToolbarCard> <ToolbarCard>
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<div className="text-lg font-semibold text-zinc-900">资源预定 / 项目管理工作台</div> <div className="text-lg font-semibold text-zinc-900">资源预定 / 项目管理工作台</div>
<div className="mt-1 text-sm text-zinc-500">
基于 `pm_project_task` 单对象模型,支持领导下发项目、成员共享进度、甘特图与个人日历联动。
</div>
</div> </div>
<button <div className="flex w-full flex-wrap items-center justify-start gap-3 sm:w-auto sm:justify-end">
type="button" <button
onClick={openCreateDialog} type="button"
className="rounded-2xl bg-[linear-gradient(135deg,#0b75b6_0%,#1095d3_55%,#18b6f6_100%)] px-5 py-2.5 text-sm font-medium text-white shadow-[0_16px_40px_rgba(17,128,196,0.2)] transition hover:brightness-105" onClick={openCreateDialog}
> className="rounded-2xl bg-[linear-gradient(135deg,#991b1b_0%,#b42318_55%,#e11d48_100%)] px-5 py-2.5 text-sm font-medium text-white shadow-[0_16px_40px_rgba(180,35,24,0.22)] transition hover:brightness-105"
新建项目 >
</button> 新建项目
</button>
<button
type="button"
onClick={() => setImportDialogOpen(true)}
className="rounded-2xl border border-zinc-200 bg-white px-5 py-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100"
>
批量导入
</button>
</div>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-3"> <div className="mt-4 flex flex-wrap items-center gap-3">
...@@ -1698,8 +2800,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1698,8 +2800,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
keyword: event.target.value, keyword: event.target.value,
})) }))
} }
placeholder="搜索项目名称 / 项目编号" placeholder="搜索项目名称 / 大类 / 项目 / 任务来源"
className="h-10 w-64 rounded-xl border border-zinc-200 px-3 text-sm text-zinc-700 outline-none transition focus:border-sky-400" className="h-10 w-64 rounded-xl border border-zinc-200 px-3 text-sm text-zinc-700 outline-none transition focus:border-rose-300"
/> />
<FilterSelect <FilterSelect
...@@ -1716,27 +2818,27 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1716,27 +2818,27 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
<div className="w-56"> <div className="w-56">
<UserSearchSelect <UserSearchSelect
value={filters.memberId} value={filters.ownerId}
onChange={(value) => onChange={(value) =>
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
memberId: value, ownerId: value,
})) }))
} }
placeholder="远程搜索成员" placeholder="远程搜索负责人"
/> />
</div> </div>
<div className="w-56"> <div className="w-56">
<UserSearchSelect <UserSearchSelect
value={filters.leaderId} value={filters.collaboratorId}
onChange={(value) => onChange={(value) =>
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
leaderId: value, collaboratorId: value,
})) }))
} }
placeholder="远程搜索领导" placeholder="远程搜索协作人"
/> />
</div> </div>
</div> </div>
...@@ -1766,8 +2868,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1766,8 +2868,8 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
) : null} ) : null}
{loading ? ( {loading ? (
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700"> <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
正在加载项目管理工作台... 正在加载...
</div> </div>
) : null} ) : null}
</ToolbarCard> </ToolbarCard>
...@@ -1776,29 +2878,25 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1776,29 +2878,25 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
<StatCard <StatCard
label="项目总数" label="项目总数"
value={stats.total} value={stats.total}
hint="当前筛选条件下的全部项目"
tone="primary" tone="primary"
/> />
<StatCard <StatCard
label="进行中" label="进行中"
value={stats.inProgress} value={stats.inProgress}
hint="共享进度仍在推进中的项目"
/> />
<StatCard <StatCard
label="已完成" label="已完成"
value={stats.completed} value={stats.completed}
hint="已经进入 completed 状态的项目"
tone="success" tone="success"
/> />
<StatCard <StatCard
label="已超时" label="已超时"
value={stats.overdue} value={stats.overdue}
hint="已超出计划结束日期或超时完成"
tone="danger" tone="danger"
/> />
</div> </div>
{activeTab === 'gantt' ? ( {activeTab === 'gantt' && hasVisibleTasks ? (
<ProjectGanttBoard <ProjectGanttBoard
tasks={visibleTasks} tasks={visibleTasks}
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
...@@ -1807,7 +2905,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1807,7 +2905,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/> />
) : null} ) : null}
{activeTab === 'calendar' ? ( {activeTab === 'calendar' && hasVisibleTasks ? (
<ProjectCalendarBoard <ProjectCalendarBoard
tasks={visibleTasks} tasks={visibleTasks}
currentUserId={currentUser?.id || ''} currentUserId={currentUser?.id || ''}
...@@ -1817,7 +2915,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1817,7 +2915,7 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/> />
) : null} ) : null}
{activeTab === 'list' ? ( {activeTab === 'list' && hasVisibleTasks ? (
<ProjectListPanel <ProjectListPanel
tasks={visibleTasks} tasks={visibleTasks}
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
...@@ -1826,11 +2924,18 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1826,11 +2924,18 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
/> />
) : null} ) : null}
{!loading && !hasVisibleTasks ? (
<EmptyWorkspaceNotice
title="当前暂无项目"
/>
) : null}
<ProjectTaskDialog <ProjectTaskDialog
open={dialogOpen} open={dialogOpen}
task={editingTask} task={editingTask}
submitting={dialogSubmitting} submitting={dialogSubmitting}
submitError={dialogError} submitError={dialogError}
fieldOptions={fieldOptions}
onClose={() => { onClose={() => {
if (dialogSubmitting) { if (dialogSubmitting) {
return; return;
...@@ -1841,6 +2946,19 @@ export default function ProjectBookingWorkspace({ currentUsername }) { ...@@ -1841,6 +2946,19 @@ export default function ProjectBookingWorkspace({ currentUsername }) {
}} }}
onSubmit={handleDialogSubmit} onSubmit={handleDialogSubmit}
/> />
<ProjectImportDialog
open={importDialogOpen}
loading={importLoading}
error={importError}
previewRows={importPreviewRows}
fileName={importFileName}
successRows={importPreviewRows.filter((row) => !row.error)}
onClose={handleImportDialogClose}
onDownloadTemplate={handleImportTemplateDownload}
onFileChange={handleImportFileChange}
onImport={handleConfirmImport}
/>
</div> </div>
); );
} }
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
- 一个项目通常只有 1-2 名参与成员 - 一个项目通常只有 1-2 名参与成员
- 多名成员共享同一项目进度与状态 - 多名成员共享同一项目进度与状态
- 只会由 1 名成员更新项目进度或状态 - 由项目负责人持续维护周进度、状态与延期原因
--- ---
...@@ -33,15 +33,21 @@ ...@@ -33,15 +33,21 @@
关键字段: 关键字段:
- `project_name`:项目名称 - `project_name`:项目名称
- `project_code`:项目编号 - `big_category`:大类
- `leader`:下发领导,直接关联系统用户 - `project`:项目归属或项目分类补充字段
- `member_ids`:参与成员,直接关联系统用户,多值 - `project_owner`:项目负责人,直接关联系统用户,多值
- `project_collaborator`:项目协作人,直接关联系统用户,多值
- `task_source`:任务来源
- `task_nature`:任务性质
- `occurrence_frequency`:发生频率
- `planned_start_date` / `planned_end_date`:计划时间 - `planned_start_date` / `planned_end_date`:计划时间
- `actual_start_date` / `actual_finish_date`:实际时间 - `actual_start_date` / `actual_finish_date`:实际时间
- `project_status`:项目当前业务状态 - `project_status`:项目当前业务状态
- `completion_percent`:共享完成进度 - `completion_percent`:共享完成进度
- `result_status`:结果状态 - `weekly_updates`:按周维护的进度记录对象
- `is_overtime` / `overtime_days`:超时标记 - `delay_reason`:延期原因说明
- `is_annual_audit_plan`:是否属于年度审计计划
- 结果状态:页面按计划时间、实际完成时间和当前日期实时派生的展示状态,不单独存字段
--- ---
...@@ -50,25 +56,30 @@ ...@@ -50,25 +56,30 @@
### 4.1 领导下发项目 ### 4.1 领导下发项目
1. 领导创建一条 `pm_project_task` 1. 领导创建一条 `pm_project_task`
2. 手工填写项目名称、项目编号、计划开始日期、计划结束日期 2. 填写项目名称、计划开始日期、计划结束日期
3. 选择下发领导 `leader` 3. 按需要补充大类、项目、任务来源、任务性质、发生频率
4. 多选参与成员 `member_ids` 4. 按需勾选是否属于年度审计计划 `is_annual_audit_plan`
5. 初始化项目状态为 `assigned` 或 `draft` 5. 选择一个或多个项目负责人 `project_owner`
6. 选择一个或多个项目协作人 `project_collaborator`
7. 初始化项目状态为 `draft`
### 4.2 成员查看项目 ### 4.2 成员查看项目
1. 成员登录系统 1. 成员登录系统
2. 日历图查询 `member_ids` 包含当前用户的项目记录 2. 日历图查询当前用户包含在 `project_owner` 或 `project_collaborator` 数组中的项目记录
3. 甘特图按项目展示所有任务 3. 甘特图按项目展示所有任务
4. 可在甘特图或日历图中查看项目名称、计划时间、共享进度、状态 4. 可在甘特图或日历图中查看项目名称、计划时间、共享进度、状态
### 4.3 成员更新项目进度 ### 4.3 成员更新项目进度
1. 由项目指定更新人修改项目记录 1. 由项目负责人或指定更新人修改项目记录
2. 更新 `completion_percent` 2. 初始状态默认为 `draft`
3. 更新 `project_status` 3. 新建弹窗在项目尚未保存落库前,只允许编辑基础字段并点击“保存项目”;此时不展示“启动任务”或“完成任务”
4. 如项目开始,填写 `actual_start_date` 4. 点击“启动项目”后,前端直接调用保存接口,状态切换为 `in_progress`,并自动带入 `actual_start_date=当天`;保存成功后关闭弹窗,如需调整实际开始日期可再次打开编辑
5. 如项目完成,填写 `actual_finish_date` 5. 点击“完成项目”后,前端直接调用保存接口,状态切换为 `completed`,并自动带入 `actual_finish_date=当天`;如 `actual_start_date` 为空也会一并补当天,完成进度不足 100 时自动补到 100,保存成功后关闭弹窗
6. 项目完成时,如当前完成进度小于 100,可默认补到 100
7. 在项目详情弹窗中通过“新增本周进度”录入一条周进度记录;如填写了周进度里的累计项目进度,保存时自动同步覆盖主记录的 `completion_percent`
8. 若已超过计划结束日期仍未完成,补充 `delay_reason`
### 4.4 状态结果判定 ### 4.4 状态结果判定
...@@ -76,17 +87,21 @@ ...@@ -76,17 +87,21 @@
- 未开始:当前日期 < `planned_start_date` 且未进入完成状态 - 未开始:当前日期 < `planned_start_date` 且未进入完成状态
- 正常进行:当前日期在计划区间内,且 `project_status` 为 `in_progress` - 正常进行:当前日期在计划区间内,且 `project_status` 为 `in_progress`
- 已超时:当前日期 > `planned_end_date` 且 `project_status` 不为 `completed` - 已超时:存在 `planned_end_date`,且当前日期 > `planned_end_date`、同时 `actual_finish_date` 为空
- 提前完成:`actual_finish_date` < `planned_end_date` - 提前完成:存在 `planned_end_date` 与 `actual_finish_date`,且 `actual_finish_date` < `planned_end_date`
- 准时完成:`actual_finish_date` = `planned_end_date` - 准时完成:存在 `planned_end_date` 与 `actual_finish_date`,且 `actual_finish_date` = `planned_end_date`
- 超时完成:`actual_finish_date` > `planned_end_date` - 超时完成:存在 `planned_end_date` 与 `actual_finish_date`,且 `actual_finish_date` > `planned_end_date`
补充约定:
- 结果状态为派生结果,前端展示与统计时按 `planned_end_date`、`actual_finish_date`、当前日期实时计算,不以手工录入值作为最终口径
- `is_overtime` 与 `overtime_days` 也按上述时间字段实时推导
- 因此“超时”判断的核心依据是“计划完成时间”和“实际完成时间”的对比;仅在任务尚未完成时,才使用“当前日期是否超过计划完成时间”判断是否处于进行中的超时状态
对应字段建议: 对应字段建议:
- `project_status`:业务状态 - `project_status`:业务状态
- `result_status`:结果状态 - `delay_reason`:延期原因
- `is_overtime`:是否超时
- `overtime_days`:超时天数
--- ---
...@@ -95,6 +110,8 @@ ...@@ -95,6 +110,8 @@
### 5.0 资源统计 ### 5.0 资源统计
- `资源统计` 菜单用于承接项目管理统计看板 - `资源统计` 菜单用于承接项目管理统计看板
- 页面布局采用“顶部概览区 + 指标卡片 + ECharts 图表面板 + 风险清单”的工作台结构
- 顶部概览区展示:当前看板说明、我的关联项目数、需优先关注数量、受阻项目数量
- 当前版本建议至少展示: - 当前版本建议至少展示:
- 项目总数 - 项目总数
- 进行中数量 - 进行中数量
...@@ -102,25 +119,55 @@ ...@@ -102,25 +119,55 @@
- 已超时数量 - 已超时数量
- 受阻数量 - 受阻数量
- 平均进度 - 平均进度
- 图表区建议固定展示:
- 项目状态分布环图
- 结果状态分布柱状图
- 进度区间分布柱状图
- 成员负载 Top N 横向柱状图
- 维度概览区保留列表化统计:
- 发生频率
- 大类
- 任务来源
- 风险清单区展示:
- 即将到期项目
- 超时项目清单
- 建议补充统计维度: - 建议补充统计维度:
- 项目状态分布 - 项目状态分布
- 项目优先级分布 - 结果状态分布(按时间规则实时派生)
- 成员负载分布 - 发生频率分布
- 大类分布
- 任务来源分布
- 负责人/协作人负载分布
- 即将到期项目 - 即将到期项目
- 超时项目清单 - 超时项目清单
- 统计口径以 `pm_project_task.metadata` 的当前字段为准,优先展示:
- `project_status`
- `occurrence_frequency`
- `big_category`
- `task_source`
### 5.1 甘特图 ### 5.1 甘特图
- 以 `pm_project_task` 为数据源 - 以 `pm_project_task` 为数据源
- 展示项目名称、计划时间、实际时间、项目状态、结果状态 - 展示项目名称、项目负责人、计划时间、实际时间、项目状态,以及按时间规则派生的结果状态
- 同时展示两个百分比:
- 时间进度百分比:按 `planned_start_date` 到 `planned_end_date` 与当前日期的关系自动计算
- 实际进度百分比:读取 `completion_percent`
- 已完成、超时、提前完成等状态需要有明显视觉区分 - 已完成、超时、提前完成等状态需要有明显视觉区分
- 甘特图条内展示最新一周的周进度总结;如存在多条周记录,以最新一条摘要为准
- 周进度记录数量可作为辅助信息一并展示,帮助判断项目是否持续更新
- 周进度明细通过项目编辑弹窗内的记录列表和新增/编辑弹窗维护
- 后续可按状态映射颜色,不必先存颜色字段 - 后续可按状态映射颜色,不必先存颜色字段
### 5.2 个人日历图 ### 5.2 个人日历图
- 当前用户只看 `member_ids` 包含自己的项目 - 当前用户只看自己出现在 `project_owner` 或 `project_collaborator` 数组中的项目
- 展示项目名称、计划时间、项目状态、共享进度 - 展示项目名称、计划时间、项目状态、共享进度
- 共享进度与共享状态对所有参与成员一致 - 日历图按 `planned_start_date` 到 `planned_end_date` 生成连续区间条,不再按天重复拆成多个独立卡片
- 当项目跨周时,区间条在对应周内自动拆段显示,但视觉上保持连续排期的阅读方式
- 共享进度与共享状态对负责人和协作人一致
- 如项目存在最新周进度,可在详情或弹窗中查看周记录内容
--- ---
...@@ -129,13 +176,15 @@ ...@@ -129,13 +176,15 @@
- 当前版本不区分成员个人独立进度 - 当前版本不区分成员个人独立进度
- 当前版本不拆分子任务 - 当前版本不拆分子任务
- 当前版本一个项目只维护一条主记录 - 当前版本一个项目只维护一条主记录
- 当前版本默认 1-2 名成员协作并共享进度 - 当前版本默认负责人 + 协作人协同维护共享进度
- 周进度记录当前存放在 `pm_project_task.metadata.weekly_updates` 中,暂不拆独立日志对象
- 前端页面仅展示真实 `pm_project_task` Record 数据;当查询结果为空或接口失败时,只允许显示空状态或错误提示,禁止再回退演示数据
--- ---
## 6.1 系统用户远程搜索示例 ## 6.1 系统用户远程搜索示例
`leader` 和 `member_ids` 使用系统用户,不单独维护业务用户对象。 `project_owner` 和 `project_collaborator` 使用系统用户,不单独维护业务用户对象,两个字段都支持多值数组。
前端远程搜索用户时,统一调用 `queryUsers`,按 `display_name` 和 `username` 模糊匹配: 前端远程搜索用户时,统一调用 `queryUsers`,按 `display_name` 和 `username` 模糊匹配:
...@@ -164,6 +213,28 @@ const result = await queryUsers(0, 20, { ...@@ -164,6 +213,28 @@ const result = await queryUsers(0, 20, {
--- ---
## 6.2 周进度记录维护约定
- `weekly_updates.items` 采用对象数组,按时间顺序追加
- 每条记录建议至少填写:`week_start_date`、`week_end_date`、`weekly_summary`
- 如填写了 `progress_percent_after_update`,保存时自动同步到主记录的 `completion_percent`,以最后一条含进度值的周记录为准
- 如存在阻塞事项,建议在周记录的 `blockers` 中体现;如果项目已延期,再同步补充主记录字段 `delay_reason`
- 当前版本周进度记录由项目负责人统一维护,不拆分到成员个人视角
- 当前前端交互不要求用户直接填写 JSON,而是通过结构化表单维护周进度记录
## 6.3 Excel 批量导入口径
- Excel 模板采用业务列头:`大类`、`项目`、`是否属于年度审计计划`、`发生频率`、`任务名称`、`任务来源`、`任务性质`、`计划启动时间`、`计划完成时间`、`时间进度`、`任务进度`、`任务状态`、`主办人`、`协办人1`
- `任务名称` 导入到 `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
- `任务进度` 支持 `20` 或 `20%`,导入到 `metadata.completion_percent`
- `任务状态` 支持中文状态值,导入时转换为 `draft` / `in_progress` / `completed` / `blocked` / `cancelled`
- `时间进度` 属于前端派生展示值,不参与系统计算;为避免丢失,导入时仅保留在 `remark`
---
## 7. 后续可扩展方向 ## 7. 后续可扩展方向
- 增加项目日志对象,记录状态变更历史 - 增加项目日志对象,记录状态变更历史
......
import { import {
buildDemoTasks,
buildUserMap, buildUserMap,
enrichProjectTask, enrichProjectTask,
fetchProjectTasks, fetchProjectTasks,
fetchSystemUsersByIds, fetchSystemUsersByIds,
loadProjectTaskFieldOptions,
resolveUserReferenceId,
} from './projectTaskService'; } from './projectTaskService';
export async function loadProjectDashboardData(currentUsername = '') { export async function loadProjectDashboardData(currentUsername = '') {
try { try {
const taskRecords = await fetchProjectTasks({}); const [taskRecords, fieldOptions] = await Promise.all([
fetchProjectTasks({}),
loadProjectTaskFieldOptions(),
]);
const userIds = Array.from( const userIds = Array.from(
new Set( new Set(
taskRecords.flatMap((task) => { taskRecords.flatMap((task) => {
const metadata = task?.metadata || {}; const metadata = task?.metadata || {};
const memberIds = Array.isArray(metadata.member_ids) const projectOwnerIds = Array.isArray(metadata.project_owner)
? metadata.member_ids ? metadata.project_owner.map(resolveUserReferenceId)
: metadata.member_ids : [resolveUserReferenceId(metadata.project_owner)].filter(Boolean);
? [metadata.member_ids] const projectCollaboratorIds = Array.isArray(metadata.project_collaborator)
: []; ? metadata.project_collaborator.map(resolveUserReferenceId)
const leaderId = : [resolveUserReferenceId(metadata.project_collaborator)].filter(Boolean);
metadata.leader?._id ||
metadata.leader?.id ||
metadata.leader?.username ||
metadata.leader ||
'';
return [...memberIds, leaderId].filter(Boolean); return [...projectOwnerIds, ...projectCollaboratorIds].filter(Boolean);
}), }),
), ),
); );
...@@ -42,29 +41,26 @@ export async function loadProjectDashboardData(currentUsername = '') { ...@@ -42,29 +41,26 @@ export async function loadProjectDashboardData(currentUsername = '') {
return { return {
tasks, tasks,
users, users,
fieldOptions,
isDemoMode: false, isDemoMode: false,
error: '', error: '',
}; };
} }
return { return {
tasks: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)), tasks: [],
users, users,
isDemoMode: true, fieldOptions,
error: '当前未读取到项目记录,已展示演示数据,可继续浏览统计看板。', isDemoMode: false,
error: '当前未读取到真实项目记录,请先创建或导入 `pm_project_task` 数据。',
}; };
} catch (error) { } catch (error) {
const fallbackUsers = [
{ id: 'yang_admin', username: 'yang_admin', displayName: 'yang' },
{ id: 'system', username: 'system', displayName: '系统' },
];
const userMap = buildUserMap(fallbackUsers);
return { return {
tasks: buildDemoTasks().map((task) => enrichProjectTask(task, userMap)), tasks: [],
users: fallbackUsers, users: [],
isDemoMode: true, fieldOptions: {},
error: error?.message || '统计数据加载失败,已切换为演示数据。', isDemoMode: false,
error: error?.message || '统计数据加载失败,请稍后重试。',
}; };
} }
} }
import { import {
createRecord, createRecord,
importRecords,
loadSubject,
loadUser, loadUser,
queryRecords, queryRecords,
queryUsers, queryUsers,
updateRecord, updateRecord,
} from '@/services/DataService'; } from '@/services/DataService';
import * as XLSX from 'xlsx';
export { importRecords };
export const PROJECT_TASK_SUBJECT = 'pm_project_task'; export const PROJECT_TASK_SUBJECT = 'pm_project_task';
export const PROJECT_STATUS_OPTIONS = [ export const PROJECT_STATUS_OPTIONS = [
{ label: '草稿', value: 'draft' }, { label: '草稿', value: 'draft' },
{ label: '已下发', value: 'assigned' },
{ label: '进行中', value: 'in_progress' }, { label: '进行中', value: 'in_progress' },
{ label: '已完成', value: 'completed' }, { label: '已完成', value: 'completed' },
{ label: '受阻', value: 'blocked' }, { label: '受阻', value: 'blocked' },
{ label: '已取消', value: 'cancelled' }, { label: '已取消', value: 'cancelled' },
]; ];
export const OCCURRENCE_FREQUENCY_OPTIONS = [
{ label: '高', value: '3' },
{ label: '中', value: '2' },
{ label: '低', value: '1' },
];
export const PRIORITY_OPTIONS = [ export const PRIORITY_OPTIONS = [
{ label: '高', value: 'high' }, { label: '高', value: 'high' },
{ label: '中', value: 'medium' }, { label: '中', value: 'medium' },
...@@ -56,10 +66,28 @@ export const PRIORITY_LABEL_MAP = PRIORITY_OPTIONS.reduce( ...@@ -56,10 +66,28 @@ export const PRIORITY_LABEL_MAP = PRIORITY_OPTIONS.reduce(
{}, {},
); );
export const OCCURRENCE_FREQUENCY_LABEL_MAP = OCCURRENCE_FREQUENCY_OPTIONS.reduce(
(accumulator, item) => {
accumulator[item.value] = item.label;
return accumulator;
},
{},
);
export function normalizeText(value) { export function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
} }
export function resolveUserReferenceId(value) {
return value?._id || value?.id || value?.username || value || '';
}
export function normalizeUserReferenceList(value) {
const values = Array.isArray(value) ? value : value ? [value] : [];
return values.map(resolveUserReferenceId).filter(Boolean);
}
export function formatDateInput(value) { export function formatDateInput(value) {
if (!value) { if (!value) {
return ''; return '';
...@@ -88,6 +116,15 @@ export function formatDateLabel(value) { ...@@ -88,6 +116,15 @@ export function formatDateLabel(value) {
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
export function formatDateValueForPayload(value) {
const dateText = formatDateInput(value);
if (!dateText) {
return '';
}
return new Date(`${dateText}T00:00:00.000Z`).toISOString();
}
export function calculateDiffDays(startDate, endDate) { export function calculateDiffDays(startDate, endDate) {
const start = formatDateInput(startDate); const start = formatDateInput(startDate);
const end = formatDateInput(endDate); const end = formatDateInput(endDate);
...@@ -100,6 +137,325 @@ export function calculateDiffDays(startDate, endDate) { ...@@ -100,6 +137,325 @@ export function calculateDiffDays(startDate, endDate) {
return Math.floor((endTime - startTime) / 86400000); return Math.floor((endTime - startTime) / 86400000);
} }
export function calculatePlannedProgressPercent(startDate, endDate, currentDate = '') {
const plannedStartDate = formatDateInput(startDate);
const plannedEndDate = formatDateInput(endDate);
const today = formatDateInput(currentDate) || new Date().toISOString().slice(0, 10);
if (!plannedStartDate || !plannedEndDate) {
return 0;
}
if (plannedStartDate >= plannedEndDate) {
if (today < plannedStartDate) {
return 0;
}
return 100;
}
if (today <= plannedStartDate) {
return 0;
}
if (today >= plannedEndDate) {
return 100;
}
const totalDays = Math.max(calculateDiffDays(plannedStartDate, plannedEndDate), 1);
const elapsedDays = Math.max(calculateDiffDays(plannedStartDate, today), 0);
return Math.max(0, Math.min(100, Math.round((elapsedDays / totalDays) * 100)));
}
export function sortWeeklyUpdates(updates = []) {
return [...updates].sort((left, right) => {
const leftKey =
formatDateInput(left?.week_start_date) ||
formatDateInput(left?.updated_at) ||
'9999-12-31';
const rightKey =
formatDateInput(right?.week_start_date) ||
formatDateInput(right?.updated_at) ||
'9999-12-31';
if (leftKey === rightKey) {
const leftUpdatedAt = formatDateInput(left?.updated_at) || '';
const rightUpdatedAt = formatDateInput(right?.updated_at) || '';
return leftUpdatedAt.localeCompare(rightUpdatedAt);
}
return leftKey.localeCompare(rightKey);
});
}
export function wrapWeeklyUpdatesValue(updates = []) {
return {
items: sortWeeklyUpdates(
(Array.isArray(updates) ? updates : []).filter((item) => item && typeof item === 'object'),
),
};
}
export function normalizeWeeklyUpdatesValue(value, options = {}) {
const { throwOnInvalid = false } = options;
if (!value) {
return [];
}
if (Array.isArray(value)) {
return sortWeeklyUpdates(value.filter((item) => item && typeof item === 'object'));
}
if (typeof value === 'object') {
const items = Array.isArray(value.items)
? value.items
: Array.isArray(value.records)
? value.records
: [];
if (items.length > 0) {
return sortWeeklyUpdates(items.filter((item) => item && typeof item === 'object'));
}
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的对象或数组');
}
return [];
}
if (typeof value === 'string') {
const text = normalizeText(value);
if (!text) {
return [];
}
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return sortWeeklyUpdates(parsed.filter((item) => item && typeof item === 'object'));
}
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.items)) {
return sortWeeklyUpdates(parsed.items.filter((item) => item && typeof item === 'object'));
}
throw new Error('周进度记录必须是 JSON 对象或数组');
} catch (error) {
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的 JSON 对象或数组');
}
return [];
}
}
if (throwOnInvalid) {
throw new Error('周进度记录需填写为合法的 JSON 对象或数组');
}
return [];
}
export function serializeWeeklyUpdatesValue(value, options = {}) {
const updates = normalizeWeeklyUpdatesValue(value, options).map((item) => ({
...item,
week_start_date: formatDateValueForPayload(item.week_start_date),
week_end_date: formatDateValueForPayload(item.week_end_date),
progress_percent_after_update:
item.progress_percent_after_update === '' || item.progress_percent_after_update == null
? ''
: Number(item.progress_percent_after_update),
blockers: normalizeText(item.blockers),
next_week_plan: normalizeText(item.next_week_plan),
weekly_summary: normalizeText(item.weekly_summary),
updated_by: resolveUserReferenceId(item.updated_by),
updated_at: formatDateValueForPayload(item.updated_at),
}));
return wrapWeeklyUpdatesValue(updates);
}
export function resolveCompletionPercentFromWeeklyUpdates(weeklyUpdates, fallbackValue = 0) {
const updates = normalizeWeeklyUpdatesValue(weeklyUpdates);
const latestProgressUpdate = [...updates]
.reverse()
.find(
(item) =>
item?.progress_percent_after_update !== '' &&
item?.progress_percent_after_update != null &&
Number.isFinite(Number(item.progress_percent_after_update)),
);
if (latestProgressUpdate) {
return Number(latestProgressUpdate.progress_percent_after_update);
}
return Number(fallbackValue || 0);
}
export function normalizeSelectOptions(options = []) {
if (!Array.isArray(options)) {
return [];
}
return options
.map((option) => ({
label: normalizeText(option?.label),
value: String(option?.value ?? '').trim(),
}))
.filter((option) => option.label && option.value);
}
function extractSubjectFields(subject) {
if (Array.isArray(subject?.fields)) {
return subject.fields;
}
if (Array.isArray(subject?.data?.fields)) {
return subject.data.fields;
}
if (Array.isArray(subject?.subject?.fields)) {
return subject.subject.fields;
}
return [];
}
function extractSubjectField(subject, fieldName) {
return extractSubjectFields(subject).find((field) => field?.name === fieldName) || null;
}
export async function loadProjectTaskFieldOptions() {
try {
const subject = await loadSubject(PROJECT_TASK_SUBJECT);
const projectStatusField = extractSubjectField(subject, 'project_status');
const priorityField = extractSubjectField(subject, 'priority');
const occurrenceFrequencyField = extractSubjectField(subject, 'occurrence_frequency');
return {
projectStatusOptions:
normalizeSelectOptions(projectStatusField?.settings?.options).length > 0
? normalizeSelectOptions(projectStatusField?.settings?.options)
: PROJECT_STATUS_OPTIONS,
priorityOptions: normalizeSelectOptions(priorityField?.settings?.options),
occurrenceFrequencyOptions:
normalizeSelectOptions(occurrenceFrequencyField?.settings?.options).length > 0
? normalizeSelectOptions(occurrenceFrequencyField?.settings?.options)
: OCCURRENCE_FREQUENCY_OPTIONS,
};
} catch (error) {
return {
projectStatusOptions: PROJECT_STATUS_OPTIONS,
priorityOptions: [],
occurrenceFrequencyOptions: OCCURRENCE_FREQUENCY_OPTIONS,
};
}
}
export const PROJECT_TASK_IMPORT_HEADERS = [
'大类',
'项目',
'是否属于年度审计计划',
'发生频率',
'任务名称',
'任务来源',
'任务性质',
'计划启动时间',
'计划完成时间',
'时间进度',
'任务进度',
'任务状态',
'主办人',
'协办人1',
];
export const PROJECT_TASK_IMPORT_EXAMPLE_ROW = [
'审计工作',
'内部专项审计审查',
'是',
'1',
'反洗钱专项审计(覆盖子公司)',
'监管规定',
'项目性',
'5月1日',
'6月30日',
'33%',
'20%',
'进行中',
'李依敏',
'牟文博',
];
export function buildProjectTaskImportWorkbook() {
const workbook = XLSX.utils.book_new();
const templateSheet = XLSX.utils.aoa_to_sheet([
PROJECT_TASK_IMPORT_HEADERS,
PROJECT_TASK_IMPORT_EXAMPLE_ROW,
]);
templateSheet['!cols'] = [
{ wch: 14 },
{ wch: 18 },
{ wch: 16 },
{ wch: 10 },
{ wch: 28 },
{ wch: 14 },
{ wch: 12 },
{ wch: 14 },
{ wch: 14 },
{ wch: 10 },
{ wch: 10 },
{ wch: 10 },
{ wch: 14 },
{ wch: 14 },
];
const guideSheet = XLSX.utils.aoa_to_sheet([
['字段', '填写说明'],
['模板示例行', '第 2 行是示例数据,正式导入前可直接覆盖或删除'],
['任务名称', '导入为项目名称 metadata.project_name'],
['主办人 / 协办人1', '填写系统用户的 display_name、username 或 _id;多人可用逗号、顿号、分号、斜杠分隔,导入时自动查询并保存为用户 _id 数组'],
['发生频率', '支持 1 / 2 / 3,也支持 高 / 中 / 低'],
['计划启动时间 / 计划完成时间', '支持 Excel 日期、YYYY-MM-DD、YYYY/MM/DD、2026年5月1日、5月1日;无年份时默认当前年份'],
['时间进度', '系统内为派生字段,导入时仅保留到备注中,不参与系统计算'],
['任务进度', '支持 20 或 20% 格式,导入为 completion_percent'],
['任务状态', '支持 草稿 / 进行中 / 已完成 / 受阻 / 已取消'],
['是否属于年度审计计划', '支持 是/否、true/false、1/0;导入到 metadata.is_annual_audit_plan'],
]);
guideSheet['!cols'] = [{ wch: 24 }, { wch: 88 }];
XLSX.utils.book_append_sheet(workbook, templateSheet, '导入模板');
XLSX.utils.book_append_sheet(workbook, guideSheet, '说明');
return workbook;
}
export function downloadProjectTaskImportTemplate(filename = '项目管理导入模板.xlsx') {
XLSX.writeFile(buildProjectTaskImportWorkbook(), filename);
}
export async function readProjectTaskImportRows(file) {
const buffer = await file.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
if (!firstSheetName) {
return [];
}
const sheet = workbook.Sheets[firstSheetName];
if (!sheet) {
return [];
}
return XLSX.utils
.sheet_to_json(sheet, {
defval: '',
raw: false,
})
.filter((row) =>
Object.values(row || {}).some((value) => String(value ?? '').trim() !== ''),
);
}
export function resolveResultStatus(task) { export function resolveResultStatus(task) {
const metadata = task?.metadata || {}; const metadata = task?.metadata || {};
const projectStatus = metadata.project_status || 'draft'; const projectStatus = metadata.project_status || 'draft';
...@@ -108,7 +464,7 @@ export function resolveResultStatus(task) { ...@@ -108,7 +464,7 @@ export function resolveResultStatus(task) {
const actualFinishDate = formatDateInput(metadata.actual_finish_date); const actualFinishDate = formatDateInput(metadata.actual_finish_date);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
if (projectStatus === 'completed' && actualFinishDate && plannedEndDate) { if (actualFinishDate && plannedEndDate) {
if (actualFinishDate < plannedEndDate) { if (actualFinishDate < plannedEndDate) {
return 'completed_early'; return 'completed_early';
} }
...@@ -118,15 +474,15 @@ export function resolveResultStatus(task) { ...@@ -118,15 +474,15 @@ export function resolveResultStatus(task) {
return 'completed_on_time'; return 'completed_on_time';
} }
if ( if (plannedEndDate && !actualFinishDate && today > plannedEndDate) {
projectStatus !== 'completed' &&
plannedEndDate &&
today > plannedEndDate
) {
return 'overdue'; return 'overdue';
} }
if (plannedStartDate && today < plannedStartDate) { if (projectStatus === 'completed') {
return plannedEndDate ? 'completed_on_time' : 'on_track';
}
if (plannedStartDate && !actualFinishDate && today < plannedStartDate) {
return 'not_started'; return 'not_started';
} }
...@@ -135,45 +491,51 @@ export function resolveResultStatus(task) { ...@@ -135,45 +491,51 @@ export function resolveResultStatus(task) {
export function enrichProjectTask(task, userMap = new Map()) { export function enrichProjectTask(task, userMap = new Map()) {
const metadata = task?.metadata || {}; const metadata = task?.metadata || {};
const memberIds = Array.isArray(metadata.member_ids)
? metadata.member_ids.filter(Boolean)
: metadata.member_ids
? [metadata.member_ids]
: [];
const plannedStartDate = formatDateInput(metadata.planned_start_date); const plannedStartDate = formatDateInput(metadata.planned_start_date);
const plannedEndDate = formatDateInput(metadata.planned_end_date); const plannedEndDate = formatDateInput(metadata.planned_end_date);
const actualFinishDate = formatDateInput(metadata.actual_finish_date); const actualFinishDate = formatDateInput(metadata.actual_finish_date);
const resultStatus = metadata.result_status || resolveResultStatus(task); const plannedProgressPercent = calculatePlannedProgressPercent(
const isOvertime = plannedStartDate,
typeof metadata.is_overtime === 'boolean' plannedEndDate,
? metadata.is_overtime );
: resultStatus === 'overdue' || resultStatus === 'completed_overdue'; const resultStatus = resolveResultStatus(task);
const overtimeDays = const isOvertime = resultStatus === 'overdue' || resultStatus === 'completed_overdue';
Number.isFinite(Number(metadata.overtime_days)) && metadata.overtime_days !== '' const overtimeDays = isOvertime
? Number(metadata.overtime_days) ? actualFinishDate && plannedEndDate && actualFinishDate > plannedEndDate
: actualFinishDate && plannedEndDate && actualFinishDate > plannedEndDate
? calculateDiffDays(plannedEndDate, actualFinishDate) ? calculateDiffDays(plannedEndDate, actualFinishDate)
: !actualFinishDate && plannedEndDate : !actualFinishDate && plannedEndDate
? Math.max(calculateDiffDays(plannedEndDate, new Date().toISOString().slice(0, 10)), 0) ? Math.max(calculateDiffDays(plannedEndDate, new Date().toISOString().slice(0, 10)), 0)
: 0; : 0
: 0;
const leaderId = const projectOwnerIds = normalizeUserReferenceList(metadata.project_owner);
metadata.leader?._id || const projectCollaboratorIds = normalizeUserReferenceList(metadata.project_collaborator);
metadata.leader?.id || const weeklyUpdates = normalizeWeeklyUpdatesValue(metadata.weekly_updates);
metadata.leader?.username || const latestWeeklyUpdate =
metadata.leader || weeklyUpdates.length > 0 ? weeklyUpdates[weeklyUpdates.length - 1] : null;
''; const legacyUserIds = [
...normalizeUserReferenceList(metadata.member_ids),
resolveUserReferenceId(metadata.leader),
];
const relatedUserIds = Array.from(
new Set(
[...projectOwnerIds, ...projectCollaboratorIds, ...legacyUserIds].filter(Boolean),
),
);
return { return {
...task, ...task,
metadata: { metadata: {
...metadata, ...metadata,
result_status: resultStatus,
is_overtime: isOvertime, is_overtime: isOvertime,
overtime_days: overtimeDays, overtime_days: overtimeDays,
weekly_updates: wrapWeeklyUpdatesValue(weeklyUpdates),
}, },
projectName: normalizeText(metadata.project_name) || task?.title || '未命名项目', projectName: normalizeText(metadata.project_name) || task?.title || '未命名项目',
projectCode: normalizeText(metadata.project_code) || '-', bigCategory: normalizeText(metadata.big_category),
projectCategory: normalizeText(metadata.project),
taskSource: normalizeText(metadata.task_source),
taskNature: normalizeText(metadata.task_nature),
projectStatus: metadata.project_status || 'draft', projectStatus: metadata.project_status || 'draft',
projectStatusLabel: projectStatusLabel:
PROJECT_STATUS_LABEL_MAP[metadata.project_status] || metadata.project_status || '-', PROJECT_STATUS_LABEL_MAP[metadata.project_status] || metadata.project_status || '-',
...@@ -183,14 +545,38 @@ export function enrichProjectTask(task, userMap = new Map()) { ...@@ -183,14 +545,38 @@ export function enrichProjectTask(task, userMap = new Map()) {
plannedEndDate, plannedEndDate,
actualStartDate: formatDateInput(metadata.actual_start_date), actualStartDate: formatDateInput(metadata.actual_start_date),
actualFinishDate, actualFinishDate,
plannedProgressPercent,
progressPercent: Number(metadata.completion_percent || 0), progressPercent: Number(metadata.completion_percent || 0),
priority: metadata.priority || '', priority: metadata.priority || '',
priorityLabel: PRIORITY_LABEL_MAP[metadata.priority] || '-', priorityLabel: PRIORITY_LABEL_MAP[metadata.priority] || '-',
memberIds, occurrenceFrequency: String(metadata.occurrence_frequency ?? '').trim(),
memberUsers: memberIds occurrenceFrequencyLabel:
OCCURRENCE_FREQUENCY_LABEL_MAP[String(metadata.occurrence_frequency ?? '').trim()] || '-',
projectOwnerIds,
projectCollaboratorIds,
relatedUserIds,
relatedUsers: relatedUserIds
.map((id) => userMap.get(id)) .map((id) => userMap.get(id))
.filter(Boolean), .filter(Boolean),
leaderUser: userMap.get(leaderId) || metadata.leader || null, projectOwnerUsers: projectOwnerIds
.map((id) => userMap.get(id) || { id })
.filter(Boolean),
projectCollaboratorUsers: projectCollaboratorIds
.map((id) => userMap.get(id) || { id })
.filter(Boolean),
projectOwnerUser:
projectOwnerIds.length > 0 ? userMap.get(projectOwnerIds[0]) || { id: projectOwnerIds[0] } : null,
projectCollaboratorUser:
projectCollaboratorIds.length > 0
? userMap.get(projectCollaboratorIds[0]) || { id: projectCollaboratorIds[0] }
: null,
delayReason: normalizeText(metadata.delay_reason),
weeklyUpdates,
latestWeeklyUpdate,
latestWeeklySummary: normalizeText(
latestWeeklyUpdate?.weekly_summary || latestWeeklyUpdate?.summary,
),
weeklyUpdateCount: weeklyUpdates.length,
isOvertime, isOvertime,
overtimeDays, overtimeDays,
dateSpanDays: dateSpanDays:
...@@ -203,8 +589,8 @@ export function enrichProjectTask(task, userMap = new Map()) { ...@@ -203,8 +589,8 @@ export function enrichProjectTask(task, userMap = new Map()) {
export function buildTaskFilter({ export function buildTaskFilter({
keyword = '', keyword = '',
status = '', status = '',
memberId = '', ownerId = '',
leaderId = '', collaboratorId = '',
}) { }) {
const conditions = []; const conditions = [];
...@@ -218,7 +604,25 @@ export function buildTaskFilter({ ...@@ -218,7 +604,25 @@ export function buildTaskFilter({
}, },
}, },
{ {
'metadata.project_code': { '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, $regex: keyword,
$options: 'i', $options: 'i',
}, },
...@@ -233,15 +637,15 @@ export function buildTaskFilter({ ...@@ -233,15 +637,15 @@ export function buildTaskFilter({
}); });
} }
if (memberId) { if (ownerId) {
conditions.push({ conditions.push({
'metadata.member_ids': memberId, 'metadata.project_owner': ownerId,
}); });
} }
if (leaderId) { if (collaboratorId) {
conditions.push({ conditions.push({
'metadata.leader': leaderId, 'metadata.project_collaborator': collaboratorId,
}); });
} }
...@@ -276,8 +680,17 @@ export function normalizeUserRecord(user) { ...@@ -276,8 +680,17 @@ export function normalizeUserRecord(user) {
id: user?._id || user?.id || user?.username || '', id: user?._id || user?.id || user?.username || '',
username: user?.username || '', username: user?.username || '',
displayName: displayName:
user?.display_name || user?.title || user?.username || user?._id || '', user?.display_name ||
raw: user, user?.displayName ||
user?.title ||
user?.username ||
user?._id ||
'',
status: user?.status || '',
type: user?.type || '',
updatedAt: user?.updated_at || user?.updatedAt || '',
createdAt: user?.created_at || user?.createdAt || '',
raw: user?.raw || user,
}; };
} }
...@@ -294,6 +707,189 @@ export function mergeUserRecords(users = []) { ...@@ -294,6 +707,189 @@ export function mergeUserRecords(users = []) {
return Array.from(userMap.values()); return Array.from(userMap.values());
} }
function extractUserQueryResults(response) {
if (Array.isArray(response?.users)) {
return response.users;
}
if (Array.isArray(response?.records)) {
return response.records;
}
return [];
}
function buildImportUserQueryFilter(keyword = '') {
const normalizedKeyword = normalizeText(keyword);
if (!normalizedKeyword) {
return '';
}
return {
$or: [
{
username: {
$regex: normalizedKeyword,
},
},
{
display_name: {
$regex: normalizedKeyword,
},
},
],
};
}
function buildBatchImportUserQueryFilter(keywords = []) {
const normalizedKeywords = Array.from(
new Set((Array.isArray(keywords) ? keywords : []).map(normalizeText).filter(Boolean)),
);
if (normalizedKeywords.length === 0) {
return '';
}
return {
$or: [
{
username: {
$in: normalizedKeywords,
},
},
{
display_name: {
$in: normalizedKeywords,
},
},
],
};
}
function normalizeImportUserCompareValue(value = '') {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
.replace(/[((]/g, '(')
.replace(/[))]/g, ')');
}
function compareImportUserPriority(leftUser, rightUser) {
const left = normalizeUserRecord(leftUser);
const right = normalizeUserRecord(rightUser);
if (left.status !== right.status) {
if (left.status === 'active') {
return -1;
}
if (right.status === 'active') {
return 1;
}
}
const leftUpdatedAt = new Date(left.updatedAt || 0).getTime();
const rightUpdatedAt = new Date(right.updatedAt || 0).getTime();
if (leftUpdatedAt !== rightUpdatedAt) {
return rightUpdatedAt - leftUpdatedAt;
}
const leftCreatedAt = new Date(left.createdAt || 0).getTime();
const rightCreatedAt = new Date(right.createdAt || 0).getTime();
if (leftCreatedAt !== rightCreatedAt) {
return rightCreatedAt - leftCreatedAt;
}
return left.displayName.localeCompare(right.displayName, 'zh-CN');
}
function findExactImportUserMatch(users = [], keyword = '') {
const normalizedKeyword = normalizeText(keyword);
if (!normalizedKeyword) {
return null;
}
const normalizedCompareKeyword = normalizeImportUserCompareValue(normalizedKeyword);
const exactDisplayNameMatches = users.filter((user) => {
const normalizedUser = normalizeUserRecord(user);
return (
normalizeImportUserCompareValue(normalizedUser.displayName) ===
normalizedCompareKeyword
);
});
if (exactDisplayNameMatches.length > 0) {
return normalizeUserRecord(
[...exactDisplayNameMatches].sort(compareImportUserPriority)[0],
);
}
const exactUsernameMatches = users.filter((user) => {
const normalizedUser = normalizeUserRecord(user);
return (
normalizeImportUserCompareValue(normalizedUser.username) ===
normalizedCompareKeyword
);
});
if (exactUsernameMatches.length > 0) {
return normalizeUserRecord(
[...exactUsernameMatches].sort(compareImportUserPriority)[0],
);
}
const exactIdMatches = users.filter((user) => {
const normalizedUser = normalizeUserRecord(user);
return normalizeImportUserCompareValue(normalizedUser.id) === normalizedCompareKeyword;
});
if (exactIdMatches.length > 0) {
return normalizeUserRecord([...exactIdMatches].sort(compareImportUserPriority)[0]);
}
if (users.length === 1) {
return normalizeUserRecord(users[0]);
}
return null;
}
async function resolveImportUserReference(keyword = '') {
const normalizedKeyword = normalizeText(keyword);
if (!normalizedKeyword) {
return {
user: null,
candidates: [],
};
}
const users = await searchSystemUsers(normalizedKeyword);
const matchedUser = findExactImportUserMatch(users, normalizedKeyword);
return {
user: matchedUser,
candidates: users,
};
}
async function searchSystemUsersByExactValues(keywords = []) {
const normalizedKeywords = Array.from(
new Set((Array.isArray(keywords) ? keywords : []).map(normalizeText).filter(Boolean)),
);
if (normalizedKeywords.length === 0) {
return [];
}
const response = await queryUsers(
0,
Math.max(normalizedKeywords.length * 5, 20),
buildBatchImportUserQueryFilter(normalizedKeywords),
null,
);
return mergeUserRecords(extractUserQueryResults(response));
}
export async function searchSystemUsers(keyword = '') { export async function searchSystemUsers(keyword = '') {
const normalizedKeyword = normalizeText(keyword); const normalizedKeyword = normalizeText(keyword);
const response = await queryUsers( const response = await queryUsers(
...@@ -317,7 +913,7 @@ export async function searchSystemUsers(keyword = '') { ...@@ -317,7 +913,7 @@ export async function searchSystemUsers(keyword = '') {
: '', : '',
null, null,
); );
return mergeUserRecords(response?.records || []); return mergeUserRecords(extractUserQueryResults(response));
} }
export async function fetchSystemUsersByIds(ids = []) { export async function fetchSystemUsersByIds(ids = []) {
...@@ -355,30 +951,219 @@ export function buildUserMap(users = []) { ...@@ -355,30 +951,219 @@ export function buildUserMap(users = []) {
); );
} }
export function splitImportUserReferences(value) {
const normalizedValue = normalizeText(value);
if (!normalizedValue) {
return [];
}
return normalizedValue
.split(/[,,、;;\/\n]+/)
.map((item) => normalizeText(item))
.filter(Boolean);
}
export async function resolveImportUserReferenceList(value, fieldLabel = '用户') {
return resolveImportUserReferenceListWithCandidates(value, fieldLabel);
}
export async function resolveImportUserReferenceListWithCandidates(
value,
fieldLabel = '用户',
candidateUsers = [],
) {
const referenceValues = splitImportUserReferences(value);
if (referenceValues.length === 0) {
return [];
}
const resolvedUsers = await Promise.all(
referenceValues.map(async (referenceValue) => ({
referenceValue,
...(Array.isArray(candidateUsers) && candidateUsers.length > 0
? {
user: findExactImportUserMatch(candidateUsers, referenceValue),
candidates: candidateUsers.filter((candidate) => {
const normalizedCandidate = normalizeUserRecord(candidate);
const compareValue = normalizeImportUserCompareValue(referenceValue);
return (
normalizeImportUserCompareValue(normalizedCandidate.displayName).includes(compareValue) ||
normalizeImportUserCompareValue(normalizedCandidate.username).includes(compareValue) ||
normalizeImportUserCompareValue(normalizedCandidate.id).includes(compareValue)
);
}),
}
: await resolveImportUserReference(referenceValue)),
})),
);
const unresolvedValues = resolvedUsers.filter((item) => !item.user?.id);
if (unresolvedValues.length > 0) {
const unresolvedMessage = unresolvedValues
.map((item) => {
const candidateLabels = (Array.isArray(item.candidates) ? item.candidates : [])
.map((candidate) => normalizeUserRecord(candidate))
.slice(0, 3)
.map((candidate) =>
candidate.displayName && candidate.username
? `${candidate.displayName}(${candidate.username})`
: candidate.displayName || candidate.username || candidate.id,
)
.filter(Boolean);
return candidateLabels.length > 0
? `${item.referenceValue},可选:${candidateLabels.join('、')}`
: item.referenceValue;
})
.join(';');
throw new Error(`${fieldLabel}未匹配到系统用户:${unresolvedMessage}`);
}
return Array.from(
new Set(resolvedUsers.map((item) => item.user.id).filter(Boolean)),
);
}
export async function resolveImportUsersForRows(rows = []) {
const allReferenceValues = Array.from(
new Set(
(Array.isArray(rows) ? rows : []).flatMap((row) => [
...splitImportUserReferences(row?.['主办人'] || row?.['项目负责人']),
...splitImportUserReferences(row?.['协办人1'] || row?.['项目协作人']),
]),
),
);
return searchSystemUsersByExactValues(allReferenceValues);
}
export function buildTaskPayload(values) { export function buildTaskPayload(values) {
const projectName = normalizeText(values.project_name);
const projectOwner = normalizeUserReferenceList(values.project_owner);
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 today = new Date().toISOString().slice(0, 10);
const serializedWeeklyUpdates = serializeWeeklyUpdatesValue(values.weekly_updates, {
throwOnInvalid: true,
});
const completionPercent = resolveCompletionPercentFromWeeklyUpdates(
serializedWeeklyUpdates,
values.completion_percent,
);
const actualStartDate =
projectStatus === 'in_progress' || projectStatus === 'completed'
? actualStartDateInput || today
: actualStartDateInput;
const actualFinishDate =
projectStatus === 'completed'
? actualFinishDateInput || today
: actualFinishDateInput;
const normalizedCompletionPercent =
projectStatus === 'completed' && Number(completionPercent || 0) < 100
? 100
: completionPercent;
if (!projectName) {
throw new Error('请填写项目名称');
}
if (!plannedStartDate || !plannedEndDate) {
throw new Error('请选择计划开始日期和计划结束日期');
}
if (plannedStartDate > plannedEndDate) {
throw new Error('计划结束日期不能早于计划开始日期');
}
return { return {
title: normalizeText(values.project_name) || '未命名项目', title: projectName || '未命名项目',
metadata: { metadata: {
project_name: normalizeText(values.project_name), project_name: projectName,
project_code: normalizeText(values.project_code), big_category: normalizeText(values.big_category),
leader: values.leader || '', project: normalizeText(values.project),
member_ids: Array.isArray(values.member_ids) ? values.member_ids : [], project_owner: projectOwner,
planned_start_date: formatDateInput(values.planned_start_date), project_collaborator: projectCollaborator,
planned_end_date: formatDateInput(values.planned_end_date), task_source: normalizeText(values.task_source),
actual_start_date: formatDateInput(values.actual_start_date), task_nature: normalizeText(values.task_nature),
actual_finish_date: formatDateInput(values.actual_finish_date), occurrence_frequency: values.occurrence_frequency || '',
planned_start_date: formatDateValueForPayload(plannedStartDate),
planned_end_date: formatDateValueForPayload(plannedEndDate),
actual_start_date: formatDateValueForPayload(actualStartDate),
actual_finish_date: formatDateValueForPayload(actualFinishDate),
description: normalizeText(values.description), description: normalizeText(values.description),
remark: normalizeText(values.remark), remark: normalizeText(values.remark),
priority: values.priority || '', priority: values.priority || '',
project_status: values.project_status || 'draft', is_annual_audit_plan: Boolean(values.is_annual_audit_plan),
completion_percent: Number(values.completion_percent || 0), project_status: projectStatus,
result_status: values.result_status || '', completion_percent: normalizedCompletionPercent,
is_overtime: Boolean(values.is_overtime), delay_reason: normalizeText(values.delay_reason),
overtime_days: Number(values.overtime_days || 0), weekly_updates: serializedWeeklyUpdates,
}, },
}; };
} }
function omitEmptyImportMetadata(metadata = {}) {
return Object.entries(metadata).reduce((accumulator, [key, value]) => {
if (value === '') {
return accumulator;
}
if (value === null || value === undefined) {
return accumulator;
}
if (Array.isArray(value) && value.length === 0) {
return accumulator;
}
if (
typeof value === 'object' &&
!Array.isArray(value) &&
value &&
Object.keys(value).length === 0
) {
return accumulator;
}
accumulator[key] = value;
return accumulator;
}, {});
}
export function buildImportTaskPayload(values) {
const payload = buildTaskPayload(values);
const metadata = payload?.metadata || {};
return {
...payload,
metadata: omitEmptyImportMetadata({
...metadata,
actual_start_date: metadata.actual_start_date || null,
actual_finish_date: metadata.actual_finish_date || null,
description: metadata.description || null,
remark: metadata.remark || null,
priority: metadata.priority || null,
delay_reason: metadata.delay_reason || null,
occurrence_frequency: metadata.occurrence_frequency || null,
task_source: metadata.task_source || null,
task_nature: metadata.task_nature || null,
big_category: metadata.big_category || null,
project: metadata.project || null,
weekly_updates:
metadata.weekly_updates &&
Array.isArray(metadata.weekly_updates.items) &&
metadata.weekly_updates.items.length > 0
? metadata.weekly_updates
: null,
}),
};
}
export async function saveProjectTask(values, currentTask) { export async function saveProjectTask(values, currentTask) {
const payload = buildTaskPayload(values); const payload = buildTaskPayload(values);
const recordName = const recordName =
...@@ -402,9 +1187,13 @@ export function buildDemoTasks() { ...@@ -402,9 +1187,13 @@ export function buildDemoTasks() {
subject: PROJECT_TASK_SUBJECT, subject: PROJECT_TASK_SUBJECT,
metadata: { metadata: {
project_name: '容诚年度项目', project_name: '容诚年度项目',
project_code: 'PM-2026-001', big_category: '审计',
leader: 'yang_admin', project: '年度专项',
member_ids: ['yang_admin', 'system'], project_owner: ['yang_admin'],
project_collaborator: ['system'],
task_source: '客户年度项目',
task_nature: '常规',
occurrence_frequency: '2',
planned_start_date: '2026-05-01', planned_start_date: '2026-05-01',
planned_end_date: '2026-06-10', planned_end_date: '2026-06-10',
actual_start_date: '2026-05-02', actual_start_date: '2026-05-02',
...@@ -414,9 +1203,21 @@ export function buildDemoTasks() { ...@@ -414,9 +1203,21 @@ export function buildDemoTasks() {
priority: 'high', priority: 'high',
project_status: 'in_progress', project_status: 'in_progress',
completion_percent: 68, completion_percent: 68,
result_status: 'on_track', delay_reason: '',
is_overtime: false, weekly_updates: wrapWeeklyUpdatesValue([
overtime_days: 0, {
week_start_date: '2026-05-04',
week_end_date: '2026-05-10',
weekly_summary: '完成项目启动会与需求拆解,确认负责人和交付排期。',
progress_percent_after_update: 25,
},
{
week_start_date: '2026-05-11',
week_end_date: '2026-05-17',
weekly_summary: '完成核心资料收集和初版底稿整理,进入执行阶段。',
progress_percent_after_update: 68,
},
]),
}, },
}, },
{ {
...@@ -425,9 +1226,13 @@ export function buildDemoTasks() { ...@@ -425,9 +1226,13 @@ export function buildDemoTasks() {
subject: PROJECT_TASK_SUBJECT, subject: PROJECT_TASK_SUBJECT,
metadata: { metadata: {
project_name: '客户并购专项', project_name: '客户并购专项',
project_code: 'PM-2026-002', big_category: '咨询',
leader: 'yang_admin', project: '并购专项',
member_ids: ['system'], project_owner: ['system'],
project_collaborator: ['yang_admin'],
task_source: '客户专项需求',
task_nature: '专项',
occurrence_frequency: '1',
planned_start_date: '2026-04-15', planned_start_date: '2026-04-15',
planned_end_date: '2026-05-25', planned_end_date: '2026-05-25',
actual_start_date: '2026-04-16', actual_start_date: '2026-04-16',
...@@ -437,9 +1242,16 @@ export function buildDemoTasks() { ...@@ -437,9 +1242,16 @@ export function buildDemoTasks() {
priority: 'medium', priority: 'medium',
project_status: 'in_progress', project_status: 'in_progress',
completion_percent: 82, completion_percent: 82,
result_status: 'overdue', delay_reason: '客户补充资料延后,导致复核节点顺延。',
is_overtime: true, weekly_updates: wrapWeeklyUpdatesValue([
overtime_days: 14, {
week_start_date: '2026-05-18',
week_end_date: '2026-05-24',
weekly_summary: '完成初稿沟通,但等待客户补材料,无法进入最终复核。',
blockers: '客户补充资料尚未齐备',
progress_percent_after_update: 82,
},
]),
}, },
}, },
{ {
...@@ -448,9 +1260,13 @@ export function buildDemoTasks() { ...@@ -448,9 +1260,13 @@ export function buildDemoTasks() {
subject: PROJECT_TASK_SUBJECT, subject: PROJECT_TASK_SUBJECT,
metadata: { metadata: {
project_name: '海外审计补充工作', project_name: '海外审计补充工作',
project_code: 'PM-2026-003', big_category: '审计',
leader: 'system', project: '海外补充',
member_ids: ['yang_admin'], project_owner: ['yang_admin'],
project_collaborator: [],
task_source: '内部安排',
task_nature: '补充工作',
occurrence_frequency: '3',
planned_start_date: '2026-03-10', planned_start_date: '2026-03-10',
planned_end_date: '2026-04-20', planned_end_date: '2026-04-20',
actual_start_date: '2026-03-11', actual_start_date: '2026-03-11',
...@@ -460,9 +1276,21 @@ export function buildDemoTasks() { ...@@ -460,9 +1276,21 @@ export function buildDemoTasks() {
priority: 'low', priority: 'low',
project_status: 'completed', project_status: 'completed',
completion_percent: 100, completion_percent: 100,
result_status: 'completed_early', delay_reason: '',
is_overtime: false, weekly_updates: wrapWeeklyUpdatesValue([
overtime_days: 0, {
week_start_date: '2026-04-07',
week_end_date: '2026-04-13',
weekly_summary: '完成补充抽样与差异复核,准备结项。',
progress_percent_after_update: 92,
},
{
week_start_date: '2026-04-14',
week_end_date: '2026-04-20',
weekly_summary: '提前完成全部交付并归档。',
progress_percent_after_update: 100,
},
]),
}, },
}, },
]; ];
......
import { UserContext } from '@/wrapper/Auth';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { import {
PRIORITY_LABEL_MAP, formatDateLabel,
OCCURRENCE_FREQUENCY_LABEL_MAP,
PROJECT_STATUS_LABEL_MAP, PROJECT_STATUS_LABEL_MAP,
RESULT_STATUS_LABEL_MAP,
} from '../projectTaskService'; } from '../projectTaskService';
import { loadProjectDashboardData } from '../projectStatisticsService'; import { loadProjectDashboardData } from '../projectStatisticsService';
const CHART_COLORS = ['#b42318', '#f97316', '#f59e0b', '#e11d48', '#7c3aed', '#0f766e'];
const RESULT_STATUS_COLORS = {
not_started: '#94a3b8',
on_track: '#b42318',
overdue: '#e11d48',
completed_early: '#0f766e',
completed_on_time: '#22c55e',
completed_overdue: '#f97316',
unset: '#d4d4d8',
};
const PROGRESS_BUCKETS = [
{ label: '0-25%', min: 0, max: 25 },
{ label: '26-50%', min: 26, max: 50 },
{ label: '51-75%', min: 51, max: 75 },
{ label: '76-99%', min: 76, max: 99 },
{ label: '100%', min: 100, max: 100 },
];
function formatUserLabel(user) { function formatUserLabel(user) {
if (!user) { if (!user) {
return '-'; return '-';
...@@ -21,47 +43,290 @@ function formatPercent(value) { ...@@ -21,47 +43,290 @@ function formatPercent(value) {
return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`; return `${Math.max(0, Math.min(100, Math.round(numericValue)))}%`;
} }
function StatCard({ label, value, hint, tone = 'default' }) { function buildDonutOption(data, total) {
return {
color: CHART_COLORS,
tooltip: {
trigger: 'item',
formatter: '{b}<br/>{c} 个 ({d}%)',
},
series: [
{
type: 'pie',
radius: ['56%', '78%'],
center: ['50%', '52%'],
itemStyle: {
borderColor: '#ffffff',
borderWidth: 4,
},
label: {
formatter: '{b}\n{c}',
color: '#334155',
fontSize: 12,
},
labelLine: {
length: 16,
length2: 10,
},
emphasis: {
scale: true,
scaleSize: 6,
},
data,
},
{
type: 'pie',
silent: true,
radius: ['0%', '42%'],
center: ['50%', '52%'],
label: {
position: 'center',
formatter: `{count|${total}}\n{name|项目总数}`,
rich: {
count: {
fontSize: 28,
fontWeight: 700,
color: '#0f172a',
lineHeight: 34,
},
name: {
fontSize: 12,
color: '#64748b',
lineHeight: 16,
},
},
},
data: [{ value: 1 }],
},
],
};
}
function buildVerticalBarOption(data, colors = []) {
return {
color: colors.length > 0 ? colors : ['#b42318'],
grid: {
top: 20,
left: 24,
right: 16,
bottom: 36,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'category',
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: '#e4e4e7',
},
},
axisLabel: {
color: '#52525b',
interval: 0,
},
data: data.map((item) => item.label),
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: {
lineStyle: {
color: '#f1f5f9',
},
},
axisLabel: {
color: '#71717a',
},
},
series: [
{
type: 'bar',
data: data.map((item, index) => ({
value: item.count,
itemStyle: colors[index]
? {
color: colors[index],
borderRadius: [12, 12, 0, 0],
}
: {
borderRadius: [12, 12, 0, 0],
},
})),
barWidth: 28,
label: {
show: true,
position: 'top',
color: '#334155',
},
},
],
};
}
function buildHorizontalBarOption(data) {
return {
color: ['#b42318'],
grid: {
top: 12,
left: 96,
right: 24,
bottom: 20,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'value',
minInterval: 1,
splitLine: {
lineStyle: {
color: '#f1f5f9',
},
},
axisLabel: {
color: '#71717a',
},
},
yAxis: {
type: 'category',
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
color: '#334155',
width: 88,
overflow: 'truncate',
},
data: data.map((item) => item.label),
},
series: [
{
type: 'bar',
barWidth: 18,
data: data.map((item) => ({
value: item.totalProjects,
itemStyle: {
borderRadius: [0, 10, 10, 0],
},
})),
label: {
show: true,
position: 'right',
color: '#0f172a',
},
},
],
};
}
function StatCard({ label, value, helper, tone = 'default' }) {
const toneClass = const toneClass =
tone === 'danger' tone === 'danger'
? 'from-rose-50 to-white border-rose-200' ? 'from-rose-50 via-white to-white border-rose-200'
: tone === 'success' : tone === 'success'
? 'from-emerald-50 to-white border-emerald-200' ? 'from-emerald-50 via-white to-white border-emerald-200'
: tone === 'primary' : tone === 'primary'
? 'from-sky-50 to-white border-sky-200' ? 'from-rose-50 via-white to-white border-rose-200'
: tone === 'warning' : tone === 'warning'
? 'from-amber-50 to-white border-amber-200' ? 'from-amber-50 via-white to-white border-amber-200'
: 'from-zinc-50 to-white border-zinc-200'; : 'from-zinc-50 via-white to-white border-zinc-200';
return ( return (
<div <div
className={clsx( className={clsx(
'rounded-2xl border bg-gradient-to-br p-5 shadow-sm', 'rounded-3xl border bg-gradient-to-br p-5 shadow-sm shadow-zinc-200/40',
toneClass, toneClass,
)} )}
> >
<div className="text-sm font-medium text-zinc-500">{label}</div> <div className="text-sm font-medium text-zinc-500">{label}</div>
<div className="mt-3 text-3xl font-semibold text-zinc-900">{value}</div> <div className="mt-3 text-3xl font-semibold tracking-tight text-zinc-950">{value}</div>
<div className="mt-2 text-sm text-zinc-500">{hint}</div> <div className="mt-2 text-xs text-zinc-500">{helper}</div>
</div> </div>
); );
} }
function SectionCard({ title, description, children }) { function SectionCard({ title, description = '', children, className = '', bodyClassName = '' }) {
return ( return (
<div className="rounded-2xl border border-zinc-200 bg-white shadow-sm"> <section
<div className="border-b border-zinc-200 px-5 py-4"> className={clsx(
<div className="text-lg font-semibold text-zinc-900">{title}</div> 'rounded-[28px] border border-zinc-200/80 bg-white shadow-sm shadow-zinc-200/40',
{description ? ( className,
<div className="mt-1 text-sm text-zinc-500">{description}</div> )}
) : null} >
<div className="border-b border-zinc-200/80 px-5 py-4">
<div className="text-lg font-semibold text-zinc-950">{title}</div>
{description ? <div className="mt-1 text-sm text-zinc-500">{description}</div> : null}
</div> </div>
<div className="px-5 py-5">{children}</div> <div className={clsx('px-5 py-5', bodyClassName)}>{children}</div>
</section>
);
}
function ChartPanel({ title, description, option, hasData }) {
return (
<SectionCard title={title} description={description}>
{hasData ? (
<ReactECharts
option={option}
notMerge
lazyUpdate
opts={{ renderer: 'svg' }}
style={{ height: 320, width: '100%' }}
/>
) : (
<div className="flex h-80 items-center justify-center rounded-3xl border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400">
暂无可展示的统计数据
</div>
)}
</SectionCard>
);
}
function DimensionList({ items, emptyText }) {
if (items.length === 0) {
return <div className="text-sm text-zinc-400">{emptyText}</div>;
}
return (
<div className="space-y-3">
{items.map((item) => (
<div
key={item.key}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4"
>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 text-sm font-medium text-zinc-800">{item.label}</div>
<div className="shrink-0 text-lg font-semibold text-zinc-950">{item.count}</div>
</div>
</div>
))}
</div> </div>
); );
} }
export default function ResourceStatPage({ currentUsername = '' }) { export default function ResourceStatPage({ currentUsername = '' }) {
const userInfo = useContext(UserContext);
const effectiveCurrentUsername = currentUsername || userInfo?.username || userInfo?._id || '';
const currentUserAliases = useMemo(
() => Array.from(new Set([effectiveCurrentUsername, userInfo?._id, userInfo?.username].filter(Boolean))),
[effectiveCurrentUsername, userInfo?._id, userInfo?.username],
);
const [tasks, setTasks] = useState([]); const [tasks, setTasks] = useState([]);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
...@@ -72,7 +337,7 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -72,7 +337,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
(async () => { (async () => {
setLoading(true); setLoading(true);
const result = await loadProjectDashboardData(currentUsername); const result = await loadProjectDashboardData(effectiveCurrentUsername);
if (cancelled) { if (cancelled) {
return; return;
} }
...@@ -85,7 +350,7 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -85,7 +350,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentUsername]); }, [effectiveCurrentUsername]);
const dashboard = useMemo(() => { const dashboard = useMemo(() => {
const total = tasks.length; const total = tasks.length;
...@@ -96,11 +361,17 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -96,11 +361,17 @@ export default function ResourceStatPage({ currentUsername = '' }) {
const avgProgress = const avgProgress =
total > 0 total > 0
? Math.round( ? Math.round(
tasks.reduce((sum, task) => sum + Number(task.progressPercent || 0), 0) / tasks.reduce((sum, task) => sum + Number(task.progressPercent || 0), 0) / total,
total,
) )
: 0; : 0;
const currentUserTaskCount =
currentUserAliases.length > 0
? tasks.filter((task) =>
task.relatedUserIds.some((userId) => currentUserAliases.includes(userId)),
).length
: 0;
const statusGroups = Object.entries( const statusGroups = Object.entries(
tasks.reduce((accumulator, task) => { tasks.reduce((accumulator, task) => {
const key = task.projectStatus || 'draft'; const key = task.projectStatus || 'draft';
...@@ -115,37 +386,89 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -115,37 +386,89 @@ export default function ResourceStatPage({ currentUsername = '' }) {
})) }))
.sort((left, right) => right.count - left.count); .sort((left, right) => right.count - left.count);
const priorityGroups = Object.entries( const resultStatusGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.resultStatus || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
)
.map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : RESULT_STATUS_LABEL_MAP[key] || key,
count,
}))
.sort((left, right) => right.count - left.count);
const frequencyGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.occurrenceFrequency || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
)
.map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : OCCURRENCE_FREQUENCY_LABEL_MAP[key] || key,
count,
}))
.sort((left, right) => right.count - left.count);
const categoryGroups = Object.entries(
tasks.reduce((accumulator, task) => { tasks.reduce((accumulator, task) => {
const key = task.priority || 'unset'; const key = task.bigCategory || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1; accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator; return accumulator;
}, {}), }, {}),
).map(([key, count]) => ({ )
key, .map(([key, count]) => ({
label: key === 'unset' ? '未设置' : PRIORITY_LABEL_MAP[key] || key, key,
count, label: key === 'unset' ? '未设置' : key,
count,
}))
.sort((left, right) => right.count - left.count);
const taskSourceGroups = Object.entries(
tasks.reduce((accumulator, task) => {
const key = task.taskSource || 'unset';
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {}),
)
.map(([key, count]) => ({
key,
label: key === 'unset' ? '未设置' : key,
count,
}))
.sort((left, right) => right.count - left.count);
const progressBuckets = PROGRESS_BUCKETS.map((bucket) => ({
...bucket,
count: tasks.filter((task) => {
const progress = Number(task.progressPercent || 0);
return progress >= bucket.min && progress <= bucket.max;
}).length,
})); }));
const memberLoad = Object.values( const memberLoad = Object.values(
tasks.reduce((accumulator, task) => { tasks.reduce((accumulator, task) => {
task.memberIds.forEach((memberId) => { task.relatedUserIds.forEach((userId) => {
if (!accumulator[memberId]) { if (!accumulator[userId]) {
const user = users.find((item) => item.id === memberId); const user = users.find((item) => item.id === userId);
accumulator[memberId] = { accumulator[userId] = {
id: memberId, id: userId,
label: formatUserLabel(user || { id: memberId }), label: formatUserLabel(user || { id: userId }),
totalProjects: 0, totalProjects: 0,
inProgressProjects: 0, inProgressProjects: 0,
overdueProjects: 0, overdueProjects: 0,
}; };
} }
accumulator[memberId].totalProjects += 1; accumulator[userId].totalProjects += 1;
if (task.projectStatus === 'in_progress') { if (task.projectStatus === 'in_progress') {
accumulator[memberId].inProgressProjects += 1; accumulator[userId].inProgressProjects += 1;
} }
if (task.isOvertime) { if (task.isOvertime) {
accumulator[memberId].overdueProjects += 1; accumulator[userId].overdueProjects += 1;
} }
}); });
return accumulator; return accumulator;
...@@ -154,15 +477,13 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -154,15 +477,13 @@ export default function ResourceStatPage({ currentUsername = '' }) {
const upcoming = [...tasks] const upcoming = [...tasks]
.filter((task) => task.plannedEndDate) .filter((task) => task.plannedEndDate)
.sort((left, right) => .sort((left, right) => String(left.plannedEndDate).localeCompare(String(right.plannedEndDate)))
String(left.plannedEndDate).localeCompare(String(right.plannedEndDate)), .slice(0, 6);
)
.slice(0, 5);
const overdueList = tasks const overdueList = [...tasks]
.filter((task) => task.isOvertime) .filter((task) => task.isOvertime)
.sort((left, right) => right.overtimeDays - left.overtimeDays) .sort((left, right) => right.overtimeDays - left.overtimeDays)
.slice(0, 5); .slice(0, 6);
return { return {
total, total,
...@@ -171,142 +492,196 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -171,142 +492,196 @@ export default function ResourceStatPage({ currentUsername = '' }) {
overdue, overdue,
blocked, blocked,
avgProgress, avgProgress,
currentUserTaskCount,
statusGroups, statusGroups,
priorityGroups, resultStatusGroups,
frequencyGroups,
categoryGroups,
taskSourceGroups,
progressBuckets,
memberLoad, memberLoad,
upcoming, upcoming,
overdueList, overdueList,
}; };
}, [tasks, users]); }, [currentUserAliases, tasks, users]);
const statusOption = useMemo(
() =>
buildDonutOption(
dashboard.statusGroups.map((item) => ({
name: item.label,
value: item.count,
})),
dashboard.total,
),
[dashboard.statusGroups, dashboard.total],
);
const resultStatusOption = useMemo(
() =>
buildVerticalBarOption(
dashboard.resultStatusGroups,
dashboard.resultStatusGroups.map((item) => RESULT_STATUS_COLORS[item.key] || '#b42318'),
),
[dashboard.resultStatusGroups],
);
const progressOption = useMemo(
() => buildVerticalBarOption(dashboard.progressBuckets, ['#fef2f2', '#fdba74', '#fb7185', '#b42318', '#0f766e']),
[dashboard.progressBuckets],
);
const memberLoadOption = useMemo(
() => buildHorizontalBarOption(dashboard.memberLoad.slice(0, 8)),
[dashboard.memberLoad],
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-2xl border border-zinc-200 bg-white px-5 py-5 shadow-sm"> <section className="overflow-hidden rounded-[32px] border border-zinc-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_top_right,_rgba(251,207,232,0.18),_transparent_28%),linear-gradient(135deg,_#ffffff_0%,_#f8fafc_46%,_#fff1f2_100%)] p-6 shadow-sm shadow-zinc-200/50">
<div className="text-xl font-semibold text-zinc-900">资源统计</div> <div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div className="mt-2 text-sm leading-6 text-zinc-500"> <div className="max-w-3xl">
这里承接项目管理的统计看板,除了总量卡片,还补充了状态分布、优先级、成员负载和超时清单,便于领导快速判断资源压力。 <div className="inline-flex items-center rounded-full border border-rose-200 bg-white/80 px-3 py-1 text-xs font-medium text-rose-700">
项目管理工作台
</div>
<div className="mt-4 text-3xl font-semibold tracking-tight text-zinc-950">
资源统计
</div>
<div className="mt-3 text-sm leading-6 text-zinc-600">
统计口径基于 `pm_project_task.metadata` 实时聚合,结果状态按计划日期、实际完成日期与当前日期前端派生。
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:min-w-[480px]">
<div className="rounded-2xl border border-white/80 bg-white/90 px-4 py-4 shadow-sm">
<div className="text-xs font-medium text-zinc-500">我的关联项目</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{dashboard.currentUserTaskCount}
</div>
</div>
<div className="rounded-2xl border border-white/80 bg-white/90 px-4 py-4 shadow-sm">
<div className="text-xs font-medium text-zinc-500">需优先关注</div>
<div className="mt-2 text-2xl font-semibold text-rose-600">{dashboard.overdue}</div>
</div>
<div className="rounded-2xl border border-white/80 bg-white/90 px-4 py-4 shadow-sm">
<div className="text-xs font-medium text-zinc-500">受阻项目</div>
<div className="mt-2 text-2xl font-semibold text-amber-600">{dashboard.blocked}</div>
</div>
</div>
</div> </div>
{error ? ( {error ? (
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"> <div className="mt-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{error} {error}
</div> </div>
) : null} ) : null}
{loading ? ( {loading ? (
<div className="mt-4 rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700"> <div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
正在加载资源统计看板... 正在加载资源统计看板...
</div> </div>
) : null} ) : null}
</div> </section>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<StatCard <StatCard
label="项目总数" label="项目总数"
value={dashboard.total} value={dashboard.total}
hint="全部项目记录" helper="当前已聚合的项目任务"
tone="primary" tone="primary"
/> />
<StatCard <StatCard
label="进行中" label="进行中"
value={dashboard.inProgress} value={dashboard.inProgress}
hint="仍在推进中的项目" helper="业务状态为进行中的项目"
/> />
<StatCard <StatCard
label="已完成" label="已完成"
value={dashboard.completed} value={dashboard.completed}
hint="已进入 completed 状态" helper="业务状态为已完成的项目"
tone="success" tone="success"
/> />
<StatCard <StatCard
label="已超时" label="已超时"
value={dashboard.overdue} value={dashboard.overdue}
hint="计划结束后未完成或超时完成" helper="按计划/实际日期派生的超时结果"
tone="danger" tone="danger"
/> />
<StatCard <StatCard
label="受阻项目" label="受阻项目"
value={dashboard.blocked} value={dashboard.blocked}
hint="当前状态为 blocked" helper="需要重点跟进的阻塞项目"
tone="warning" tone="warning"
/> />
<StatCard <StatCard
label="平均进度" label="平均进度"
value={formatPercent(dashboard.avgProgress)} value={formatPercent(dashboard.avgProgress)}
hint="全部项目平均完成度" helper="completion_percent 平均值"
/>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<ChartPanel
title="项目状态分布"
description="按 `metadata.project_status` 聚合,帮助快速识别进行中、完成与受阻占比。"
option={statusOption}
hasData={dashboard.statusGroups.length > 0}
/>
<ChartPanel
title="结果状态分布"
description="按计划完成时间、实际完成时间与当前日期派生结果状态。"
option={resultStatusOption}
hasData={dashboard.resultStatusGroups.length > 0}
/> />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"> <div className="grid gap-6 xl:grid-cols-[1fr_1fr_0.9fr]">
<ChartPanel
title="进度区间分布"
description="按 `completion_percent` 分桶,快速查看项目推进层级。"
option={progressOption}
hasData={dashboard.progressBuckets.some((item) => item.count > 0)}
/>
<ChartPanel
title="成员负载 Top 8"
description="按负责人和协作人的关联项目数汇总,便于观察资源分配。"
option={memberLoadOption}
hasData={dashboard.memberLoad.length > 0}
/>
<SectionCard <SectionCard
title="状态分布" title="维度概览"
description="观察当前项目状态结构,判断项目池是否过度集中在进行中或超时状态。" description="保留发生频率、大类、任务来源三类核心统计维度。"
bodyClassName="space-y-5"
> >
<div className="space-y-3"> <div>
{dashboard.statusGroups.length > 0 ? ( <div className="mb-3 text-sm font-medium text-zinc-500">发生频率</div>
dashboard.statusGroups.map((item) => { <DimensionList items={dashboard.frequencyGroups.slice(0, 3)} emptyText="暂无发生频率统计数据" />
const percentage =
dashboard.total > 0
? Math.round((item.count / dashboard.total) * 100)
: 0;
return (
<div key={item.key}>
<div className="mb-2 flex items-center justify-between text-sm text-zinc-600">
<span>{item.label}</span>
<span>
{item.count} 个 / {percentage}%
</span>
</div>
<div className="h-3 rounded-full bg-zinc-100">
<div
className="h-3 rounded-full bg-[var(--pm-brand)]"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})
) : (
<div className="text-sm text-zinc-400">暂无状态统计数据</div>
)}
</div> </div>
</SectionCard>
<SectionCard <div>
title="优先级分布" <div className="mb-3 text-sm font-medium text-zinc-500">大类</div>
description="用于判断高优先级项目是否过多,以及是否存在大量未设置优先级的项目。" <DimensionList items={dashboard.categoryGroups.slice(0, 3)} emptyText="暂无大类统计数据" />
> </div>
<div className="grid gap-3 sm:grid-cols-2">
{dashboard.priorityGroups.length > 0 ? ( <div>
dashboard.priorityGroups.map((item) => ( <div className="mb-3 text-sm font-medium text-zinc-500">任务来源</div>
<div <DimensionList items={dashboard.taskSourceGroups.slice(0, 3)} emptyText="暂无任务来源统计数据" />
key={item.key}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4"
>
<div className="text-sm text-zinc-500">{item.label}</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">
{item.count}
</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无优先级统计数据</div>
)}
</div> </div>
</SectionCard> </SectionCard>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<SectionCard <SectionCard
title="成员负载" title="成员负载明细"
description="统计每位成员参与的项目数量、进行中项目数量和超时项目数量,适合观察资源压力。" description="展示每位成员的关联项目、进行中项目和超时项目数量。"
bodyClassName="px-0 py-0"
> >
<div className="overflow-hidden rounded-2xl border border-zinc-200"> <div className="overflow-hidden rounded-[28px]">
<div className="grid grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr] bg-zinc-50 text-sm font-medium text-zinc-500"> <div className="grid grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr] bg-zinc-50 text-sm font-medium text-zinc-500">
<div className="px-4 py-3">成员</div> <div className="px-5 py-3">成员</div>
<div className="px-4 py-3 text-center">参与项目</div> <div className="px-5 py-3 text-center">参与项目</div>
<div className="px-4 py-3 text-center">进行中</div> <div className="px-5 py-3 text-center">进行中</div>
<div className="px-4 py-3 text-center">超时</div> <div className="px-5 py-3 text-center">超时</div>
</div> </div>
{dashboard.memberLoad.length > 0 ? ( {dashboard.memberLoad.length > 0 ? (
dashboard.memberLoad.map((member) => ( dashboard.memberLoad.map((member) => (
...@@ -314,16 +689,16 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -314,16 +689,16 @@ export default function ResourceStatPage({ currentUsername = '' }) {
key={member.id} key={member.id}
className="grid grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr] border-t border-zinc-100 text-sm text-zinc-700" className="grid grid-cols-[1.4fr_0.8fr_0.8fr_0.8fr] border-t border-zinc-100 text-sm text-zinc-700"
> >
<div className="px-4 py-3 font-medium text-zinc-900">{member.label}</div> <div className="px-5 py-3.5 font-medium text-zinc-950">{member.label}</div>
<div className="px-4 py-3 text-center">{member.totalProjects}</div> <div className="px-5 py-3.5 text-center">{member.totalProjects}</div>
<div className="px-4 py-3 text-center">{member.inProgressProjects}</div> <div className="px-5 py-3.5 text-center">{member.inProgressProjects}</div>
<div className="px-4 py-3 text-center text-rose-600"> <div className="px-5 py-3.5 text-center text-rose-600">
{member.overdueProjects} {member.overdueProjects}
</div> </div>
</div> </div>
)) ))
) : ( ) : (
<div className="px-4 py-10 text-center text-sm text-zinc-400"> <div className="px-5 py-12 text-center text-sm text-zinc-400">
暂无成员负载数据 暂无成员负载数据
</div> </div>
)} )}
...@@ -333,7 +708,7 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -333,7 +708,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
<div className="space-y-6"> <div className="space-y-6">
<SectionCard <SectionCard
title="即将到期" title="即将到期"
description="优先关注计划结束日期最近的项目。" description="按计划完成日期升序展示,便于提前提醒负责人。"
> >
<div className="space-y-3"> <div className="space-y-3">
{dashboard.upcoming.length > 0 ? ( {dashboard.upcoming.length > 0 ? (
...@@ -342,9 +717,10 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -342,9 +717,10 @@ export default function ResourceStatPage({ currentUsername = '' }) {
key={task._id || task.id || task.name} key={task._id || task.id || task.name}
className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4" className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4"
> >
<div className="text-sm font-semibold text-zinc-900">{task.projectName}</div> <div className="text-sm font-semibold text-zinc-950">{task.projectName}</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs leading-5 text-zinc-500">
截止 {task.plannedEndDate} · {task.projectStatusLabel} · {formatPercent(task.progressPercent)} 截止 {formatDateLabel(task.plannedEndDate)} · {task.projectStatusLabel} ·{' '}
{task.resultStatusLabel} · {formatPercent(task.progressPercent)}
</div> </div>
</div> </div>
)) ))
...@@ -356,7 +732,7 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -356,7 +732,7 @@ export default function ResourceStatPage({ currentUsername = '' }) {
<SectionCard <SectionCard
title="超时清单" title="超时清单"
description="快速查看超时最明显的项目,便于优先处理。" description="按超时天数倒序展示,帮助工作台聚焦风险项目。"
> >
<div className="space-y-3"> <div className="space-y-3">
{dashboard.overdueList.length > 0 ? ( {dashboard.overdueList.length > 0 ? (
...@@ -365,9 +741,10 @@ export default function ResourceStatPage({ currentUsername = '' }) { ...@@ -365,9 +741,10 @@ export default function ResourceStatPage({ currentUsername = '' }) {
key={task._id || task.id || task.name} key={task._id || task.id || task.name}
className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4" className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4"
> >
<div className="text-sm font-semibold text-zinc-900">{task.projectName}</div> <div className="text-sm font-semibold text-zinc-950">{task.projectName}</div>
<div className="mt-1 text-xs text-rose-700"> <div className="mt-1 text-xs leading-5 text-rose-700">
超时 {task.overtimeDays} 天 · {task.projectStatusLabel} 超时 {task.overtimeDays} 天 · {task.projectStatusLabel} ·{' '}
{task.resultStatusLabel}
</div> </div>
</div> </div>
)) ))
......
...@@ -55,9 +55,10 @@ type Record = { ...@@ -55,9 +55,10 @@ type Record = {
```json ```json
{ {
"metadata": { "metadata": {
"leader": "69b12a47e92b1de77c94a9f5", "project_owner": [
"member_ids": [ "69b12a47e92b1de77c94a9f5"
"69b12a47e92b1de77c94a9f5", ],
"project_collaborator": [
"69b12a47e92b1de77c94a9f6" "69b12a47e92b1de77c94a9f6"
] ]
} }
...@@ -66,8 +67,8 @@ type Record = { ...@@ -66,8 +67,8 @@ type Record = {
### 2.2 `date` ### 2.2 `date`
- 建议统一使用 `YYYY-MM-DD` - 表单展示可使用 `YYYY-MM-DD` 或 `datetime-local`
- 如后端要求完整时间,可扩展为 ISO 字符串 - 写入 `metadata` 时,建议统一转换为 ISO 8601 字符串(`YYYY-MM-DDTHH:mm:ss.sssZ`)
### 2.3 其他常见类型 ### 2.3 其他常见类型
...@@ -75,6 +76,7 @@ type Record = { ...@@ -75,6 +76,7 @@ type Record = {
- `number`:数字 - `number`:数字
- `boolean`:`true / false` - `boolean`:`true / false`
- `select`:传选项的存储值,不传显示文案 - `select`:传选项的存储值,不传显示文案
- `object`:对象类型时建议内部维护结构化子字段或 `items` 数组,前端优先使用结构化表单编辑,无现成交互时再退回 JSON 校验方案
--- ---
...@@ -87,9 +89,13 @@ type Record = { ...@@ -87,9 +89,13 @@ type Record = {
| 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 | | 字段标签 | 字段别名 | 字段类型 | 是否主键 | 是否多值 | 是否必填 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 项目名称 | `project_name` | text | 否 | 否 | 是 | | 项目名称 | `project_name` | text | 否 | 否 | 是 |
| 项目编号 | `project_code` | text | 是 | 否 | 否 | | 大类 | `big_category` | text | 否 | 否 | 否 |
| 下发领导 | `leader` | user | 否 | 否 | 是 | | 项目 | `project` | text | 否 | 否 | 否 |
| 参与成员 | `member_ids` | user | 否 | 是 | 是 | | 项目负责人 | `project_owner` | user | 否 | 是 | 否 |
| 项目协作人 | `project_collaborator` | user | 否 | 是 | 否 |
| 任务来源 | `task_source` | text | 否 | 否 | 否 |
| 任务性质 | `task_nature` | text | 否 | 否 | 否 |
| 发生频率 | `occurrence_frequency` | select | 否 | 否 | 否 |
| 计划开始日期 | `planned_start_date` | date | 否 | 否 | 是 | | 计划开始日期 | `planned_start_date` | date | 否 | 否 | 是 |
| 计划结束日期 | `planned_end_date` | date | 否 | 否 | 是 | | 计划结束日期 | `planned_end_date` | date | 否 | 否 | 是 |
| 实际开始日期 | `actual_start_date` | text | 否 | 否 | 否 | | 实际开始日期 | `actual_start_date` | text | 否 | 否 | 否 |
...@@ -99,62 +105,95 @@ type Record = { ...@@ -99,62 +105,95 @@ type Record = {
| 优先级 | `priority` | select | 否 | 否 | 否 | | 优先级 | `priority` | select | 否 | 否 | 否 |
| 项目状态 | `project_status` | select | 否 | 否 | 是 | | 项目状态 | `project_status` | select | 否 | 否 | 是 |
| 完成进度 | `completion_percent` | number | 否 | 否 | 否 | | 完成进度 | `completion_percent` | number | 否 | 否 | 否 |
| 结果状态 | `result_status` | select | 否 | 否 | 否 | | 延期原因 | `delay_reason` | text | 否 | 否 | 否 |
| 是否超时 | `is_overtime` | boolean | 否 | 否 | 否 | | 周进度记录 | `weekly_updates` | object | 否 | 否 | 否 |
| 超时天数 | `overtime_days` | number | 否 | 否 | 否 | | 是否属于年度审计计划 | `is_annual_audit_plan` | boolean | 否 | 否 | 否 |
### 3.1.1 `project_status` 选项 ### 3.1.1 `project_status` 选项
| 显示值 | 存储值 | | 显示值 | 存储值 |
| --- | --- | | --- | --- |
| 草稿 | `draft` | | 草稿 | `draft` |
| 已下发 | `assigned` |
| 进行中 | `in_progress` | | 进行中 | `in_progress` |
| 已完成 | `completed` | | 已完成 | `completed` |
| 受阻 | `blocked` | | 受阻 | `blocked` |
| 已取消 | `cancelled` | | 已取消 | `cancelled` |
### 3.1.2 `priority` 建议选项 ### 3.1.2 `occurrence_frequency` 选项
| 显示值 | 存储值 | | 显示值 | 存储值 |
| --- | --- | | --- | --- |
| 高 | `high` | | 高 | `3` |
| 中 | `medium` | | 中 | `2` |
| 低 | `low` | | 低 | `1` |
### 3.1.3 `result_status` 建议选项 ### 3.1.3 `priority` 配置说明
| 显示值 | 存储值 | - 当前最新结构中,`priority` 为 `select` 类型
| --- | --- | - 但字段定义内暂未提供 `settings.options`
| 未开始 | `not_started` | - 前端生成表单时不得硬编码选项,需等对象配置或接口返回的 `settings.options`
| 正常进行 | `on_track` |
| 已超时 | `overdue` | ### 3.1.4 `weekly_updates` 子结构建议
| 提前完成 | `completed_early` |
| 准时完成 | `completed_on_time` | `weekly_updates` 当前字段类型为 `object`。如业务上继续沿用“周进度记录集合”方案,建议对象内维护 `items` 数组,每项至少包含以下字段:
| 超时完成 | `completed_overdue` |
| 字段标签 | 字段别名 | 字段类型 | 是否必填 | 说明 |
| --- | --- | --- | --- | --- |
| 周开始日期 | `week_start_date` | date | 否 | 本周记录起始日期 |
| 周结束日期 | `week_end_date` | date | 否 | 本周记录结束日期 |
| 本周完成内容 | `weekly_summary` | text | 是 | 本周实际完成事项 |
| 更新后累计进度 | `progress_percent_after_update` | number | 否 | 本次更新后的累计完成进度 |
| 当前阻塞事项 | `blockers` | text | 否 | 当前风险、卡点、等待项 |
| 下周计划 | `next_week_plan` | text | 否 | 下一周推进安排 |
| 更新人 | `updated_by` | user | 否 | 记录本次周进度的人 |
| 更新时间 | `updated_at` | date | 否 | 本次周进度的录入时间 |
### 3.1.4 metadata 示例 前端交互建议:
- 项目基础信息和周进度记录分开维护
- 周进度记录通过“新增本周进度 / 编辑周进度”弹窗录入
- 列表页或项目详情只展示最近一条摘要,不直接暴露底层 JSON
- 若后端最终确认 `weekly_updates` 直接存数组,可再将本节从“对象内 `items` 数组”调整为“字段直接存数组”
### 3.1.5 metadata 示例
```json ```json
{ {
"project_name": "某客户 2026 年专项项目", "project_name": "某客户 2026 年专项项目",
"project_code": "PM-2026-001", "big_category": "审计",
"leader": "69b12a47e92b1de77c94a9f5", "project": "2026 年专项项目",
"member_ids": [ "project_owner": [
"69b12a47e92b1de77c94a9f6", "69b12a47e92b1de77c94a9f6"
],
"project_collaborator": [
"69b12a47e92b1de77c94a9f7" "69b12a47e92b1de77c94a9f7"
], ],
"planned_start_date": "2026-05-01", "task_source": "客户年度项目",
"planned_end_date": "2026-06-10", "task_nature": "常规",
"occurrence_frequency": "2",
"planned_start_date": "2026-05-01T00:00:00.000Z",
"planned_end_date": "2026-06-10T00:00:00.000Z",
"actual_start_date": "2026-05-03", "actual_start_date": "2026-05-03",
"actual_finish_date": "", "actual_finish_date": "",
"description": "领导在甘特图中下发项目,成员在日历图中查看关联项目。", "description": "项目管理对象最新字段示例。",
"remark": "一版默认 1-2 人,进度共享。", "remark": "结构已按最新字段同步。",
"priority": "high", "priority": "",
"project_status": "in_progress", "project_status": "in_progress",
"completion_percent": 60, "completion_percent": 60,
"result_status": "on_track", "delay_reason": "",
"is_overtime": false, "weekly_updates": {
"overtime_days": 0 "items": [
{
"week_start_date": "2026-05-04T00:00:00.000Z",
"week_end_date": "2026-05-10T00:00:00.000Z",
"weekly_summary": "完成项目启动会和资料清单确认。",
"progress_percent_after_update": 20,
"blockers": "",
"next_week_plan": "推进底稿准备并和客户确认时间表。",
"updated_by": "69b12a47e92b1de77c94a9f6",
"updated_at": "2026-05-10T00:00:00.000Z"
}
]
}
} }
``` ```
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