重构项目合同轻台账与详情命令面板(05-30-project-contract-ledger-detail-shell)

这个提交包含在:
2026-05-30 12:50:03 +08:00
父节点 dd1b655122
当前提交 fed797d19b
共修改 6 个文件,包含 253 行新增和 128 行删除
+107 -73
查看文件
@@ -65,7 +65,7 @@ export function ProjectsPage() {
const q = searchParams.get("q") ?? "";
const status = (searchParams.get("status") as "all" | ProjectStatus | null) ?? "all";
const mine = searchParams.get("mine") === "1";
const mine = searchParams.get("mine") !== "0";
const canManageProjects = hasAnyPermission(permissions, ["project.manage", "system.settings", "report.dashboard.view"]);
@@ -78,6 +78,7 @@ export function ProjectsPage() {
const [targetEndAt, setTargetEndAt] = useState("");
const [isConfidential, setIsConfidential] = useState(false);
const [showTrialData, setShowTrialData] = useState(false);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const listQuery = useQuery({
queryKey: ["projects", "list", status, mine, q],
@@ -101,15 +102,26 @@ export function ProjectsPage() {
);
const projectItems = listQuery.data?.items ?? [];
const visibleProjectItems = useMemo(
() =>
showTrialData
? projectItems
: projectItems.filter(
(item) => !hasTrialNoise(item.projectNo, item.name, item.clientName, item.clientAlias, item.summary),
),
[projectItems, showTrialData],
);
const visibleProjectItems = useMemo(() => {
const formalItems = showTrialData
? projectItems
: projectItems.filter(
(item) => !hasTrialNoise(item.projectNo, item.name, item.clientName, item.clientAlias, item.summary),
);
return [...formalItems].sort((a, b) => {
const score = (item: ProjectListItem) => {
let value = 0;
if (item.status === "active") value += 40;
if (item.counts.openRisks > 0) value += 30;
if (item.counts.overdueMilestones > 0) value += 20;
if (item.counts.highPriorityTasks > 0) value += 10;
return value;
};
const scoreDelta = score(b) - score(a);
if (scoreDelta !== 0) return scoreDelta;
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
});
}, [projectItems, showTrialData]);
const hiddenTrialProjectCount = projectItems.length - visibleProjectItems.length;
const projectStats = useMemo(() => {
return visibleProjectItems.reduce(
@@ -166,8 +178,8 @@ export function ProjectsPage() {
else params.delete("status");
}
if (next.mine !== undefined) {
if (next.mine) params.set("mine", "1");
else params.delete("mine");
if (next.mine) params.delete("mine");
else params.set("mine", "0");
}
setSearchParams(params, { replace: true });
}
@@ -182,20 +194,95 @@ export function ProjectsPage() {
<Button asChild variant="secondary" type="button">
<Link to="/contracts-registry">合同台账</Link>
</Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
</div>
}
/>
<StatStrip columns={4}>
<MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} />
<MetricCard title="我相关项目" value={projectStats.total} note={mine ? "默认只看我负责/我参与" : "已切换到全范围"} />
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
</StatStrip>
{showLedgerTools ? (
<SectionPanel title="台账工具" description="新建项目、显示测试数据和全范围查看放在这里,避免普通首屏变成配置台。">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr),260px]">
<div>
{!canManageProjects ? (
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
) : (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
<option value="">未指定</option>
{managerCandidates.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>开始日期</Label>
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
项目含涉密内容(正文与附件继续离线保存)
</label>
<Button
type="button"
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建项目"}
</Button>
</div>
)}
</div>
<div className="space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
<div className="font-medium text-slate-900">低频查看</div>
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button>
<p>试运行默认隐藏 TEST/SMOKE 项目。全范围查看请使用筛选区的范围按钮,避免普通项目负责人默认进入公司全量项目。</p>
</div>
</div>
</SectionPanel>
) : null}
<div className="page-shell-grid">
<div className="space-y-6">
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px,160px]">
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
{STATUS_OPTIONS.map((item) => (
@@ -205,10 +292,7 @@ export function ProjectsPage() {
))}
</select>
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
{mine ? "只看我的" : "切换到我的"}
</Button>
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
{mine ? "我相关" : "全范围"}
</Button>
</FilterBar>
@@ -259,61 +343,11 @@ export function ProjectsPage() {
</SectionPanel>
</div>
<SectionPanel title="新建项目" description="先建主档,再补成员、里程碑与风险。">
{!canManageProjects ? (
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
<option value="">未指定</option>
{managerCandidates.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo}
</option>
))}
</select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>开始日期</Label>
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
项目含涉密内容(正文与附件继续离线保存)
</label>
<Button
type="button"
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建项目"}
</Button>
</div>
)}
<SectionPanel title="下一步提示" description="项目页只保留轻台账和详情入口。">
<div className="space-y-3 text-sm text-zinc-600">
<p>项目列表用于看状态、风险、负责人和进入详情;成员、里程碑、风险、交付记录和关联业务在详情页处理。</p>
<p>合同、采购、报销和付款只展示摘要与跳转,不在项目列表直接处理对方模块动作。</p>
</div>
</SectionPanel>
</div>
</ListWorkspace>