R29-1 R29-6: 重构海音达工作台壳层与高频页面

这个提交包含在:
2026-03-23 09:59:35 +08:00
父节点 f9198a781a
当前提交 7e183c0cdc
共修改 33 个文件,包含 2193 行新增和 1564 行删除
+384 -375
查看文件
@@ -5,8 +5,12 @@ import { fetchEmployees } from "../api/employees";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
import { fetchContractsRegistry } from "../api/contractsRegistry";
import { mapApiErrorMessage } from "../api/errorMessages";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -486,10 +490,19 @@ export function ProjectDetailPage() {
const riskItems = risksQuery.data?.items ?? detail.risks;
return (
<div className="space-y-6">
<PageHeader
<PageScaffold>
<EntityOverviewHero
title={`${detail.projectNo} · ${detail.name}`}
subtitle="项目主档、成员、里程碑、任务、风险、费用、周报与资料引用统一查看。"
subtitle="项目概览、执行、留痕与归档在同一工作台内查看和处理。"
meta={
<>
<StatusBadge status={detail.status} />
{detail.isConfidential ? <span className="badge-warning">涉密项目</span> : null}
<span className="info-chip">客户:{detail.clientAlias || detail.clientName}</span>
<span className="info-chip">负责人:{detail.owner?.name ?? "-"}</span>
<span className="info-chip">项目经理:{detail.manager?.name ?? "-"}</span>
</>
}
actions={
<div className="flex flex-wrap gap-2">
<Button asChild variant="secondary" type="button">
@@ -505,14 +518,14 @@ export function ProjectDetailPage() {
}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<div className="card-std p-4"><div className="text-sm text-zinc-500">成员</div><div className="mt-2 text-2xl font-bold">{counts?.members ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">里程碑</div><div className="mt-2 text-2xl font-bold">{counts?.milestones ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">任务</div><div className="mt-2 text-2xl font-bold">{counts?.tasks ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">风险</div><div className="mt-2 text-2xl font-bold">{counts?.risks ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">报销合计</div><div className="mt-2 text-2xl font-bold">{expensesQuery.data?.totalAmount ?? "0.00"}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">差旅合计</div><div className="mt-2 text-2xl font-bold">{travelQuery.data?.totalAmount ?? "0.00"}</div></div>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<MetricCard title="成员" value={counts?.members ?? 0} note="执行角色与观察者" />
<MetricCard title="里程碑" value={counts?.milestones ?? 0} note="用于跟踪关键节点" />
<MetricCard title="任务" value={counts?.tasks ?? 0} note="按优先级与状态推进" />
<MetricCard title="风险" value={counts?.risks ?? 0} note="高风险建议指定责任人" tone={(counts?.risks ?? 0) > 0 ? "warning" : "neutral"} />
<MetricCard title="报销合计" value={expensesQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联报销" />
<MetricCard title="差旅合计" value={travelQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联差旅" />
</section>
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
<div className="card-std border-amber-200 bg-amber-50 p-4" data-testid="project-closure-warning">
@@ -655,379 +668,375 @@ export function ProjectDetailPage() {
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-zinc-900">项目主档</h3>
<StatusBadge status={detail.status} />
{detail.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密项目</span> : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>状态</Label>
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="space-y-2">
<Label>开始日期</Label>
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>实际完成</Label>
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
<option value="">未指定</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
项目包含涉密资料,正文与附件保持离线管理
</label>
<div className="flex justify-end">
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
</Button>
</div>
</div>
<div className="card-std space-y-3 p-6 text-sm">
<h3 className="text-base font-semibold text-zinc-900">项目摘要</h3>
<div>负责人:{detail.owner?.name ?? "-"}</div>
<div>项目经理:{detail.manager?.name ?? "-"}</div>
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
<div>合同台账:{counts?.contracts ?? 0}</div>
<div>交付记录:{counts?.deliveries ?? 0}</div>
<div>资料引用:{counts?.documentRefs ?? 0}</div>
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">成员</h3>
<div className="text-sm text-zinc-500">当前 {memberItems.length} 人</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
<option value="">选择成员</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
添加成员
</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
<TableBody>
{memberItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
</select>
</TableCell>
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
<TableCell>
{item.leftAt ? "已移出" : (
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">里程碑</h3>
<div className="text-sm text-zinc-500">逾期请及时调整状态</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{milestoneItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.title}</TableCell>
<TableCell>{item.dueDate ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">任务</h3><div className="text-sm text-zinc-500">支持高优先级与阻塞状态</div></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
<option value="">未指派</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{taskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
<option value="">未指派</option>
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">风险</h3><div className="text-sm text-zinc-500">问题与阻塞统一留痕</div></div>
<div className="space-y-3">
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
<option value="">未指定责任人</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{riskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.detail}</div>
</TableCell>
<TableCell>{item.level}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">执行面板</h3><div className="text-sm text-zinc-500">人、事、费、周报统一查看</div></div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">报销</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.formNumber} · {item.totalAmount}
</Link>
))}
<div className="detail-shell-grid">
<div className="space-y-6">
<SectionPanel title="概览与主档" description="先看主信息,再统一维护时间、负责人和涉密属性。">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>状态</Label>
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="space-y-2">
<Label>开始日期</Label>
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>实际完成</Label>
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
<option value="">未指定</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
项目包含涉密资料,正文与附件保持离线管理
</label>
<div className="flex justify-end">
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
</Button>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">差旅</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.requestNo} · {item.estimatedAmount}
</Link>
))}
</SectionPanel>
<div className="grid gap-6 xl:grid-cols-2">
<SectionPanel title="执行 · 成员" description={`当前 ${memberItems.length} 人`}>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
<option value="">选择成员</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
添加成员
</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
<TableBody>
{memberItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
</select>
</TableCell>
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
<TableCell>
{item.leftAt ? "已移出" : (
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">项目周报</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
</Link>
))}
</SectionPanel>
<SectionPanel title="执行 · 里程碑" description="逾期请及时调整状态。">
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{milestoneItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.title}</TableCell>
<TableCell>{item.dueDate ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">合同台账</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
{item.contractNo} · {item.counterpartyName}
</SectionPanel>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<SectionPanel title="执行 · 任务" description="支持高优先级与阻塞状态。">
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
<option value="">未指派</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{taskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
<option value="">未指派</option>
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
<SectionPanel title="执行 · 风险" description="问题与阻塞统一留痕。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
<option value="">未指定责任人</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{riskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.detail}</div>
</TableCell>
<TableCell>{item.level}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<SectionPanel title="留痕 · 交付记录" description="现场、验收、交接统一留痕。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
</select>
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
<TableBody>
{(deliveriesQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.summary}</div>
</TableCell>
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
<SectionPanel title="归档与合规 · 涉密资料引用" description="只登记目录、链接和归档位置,正文与附件仍离线管理。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
<div className="grid gap-3 md:grid-cols-2">
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
</div>
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
</select>
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
</select>
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
<TableBody>
{(docsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
</TableCell>
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
</div>
<SectionPanel title="留痕 · 项目周报关联" description="按项目查看每周进展、风险与下一步动作。">
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
<TableBody>
{(weeklyQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? "-"}</TableCell>
<TableCell>{item.weekStart ?? "-"}</TableCell>
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
<TableCell>{item.nextStepText || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionPanel>
</div>
<div className="space-y-6">
<DetailSideMeta title="项目摘要" description="优先帮助项目负责人判断当前状态、关联量和下一步动作。">
<div>负责人:{detail.owner?.name ?? "-"}</div>
<div>项目经理:{detail.manager?.name ?? "-"}</div>
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
<div>合同台账:{counts?.contracts ?? 0}</div>
<div>交付记录:{counts?.deliveries ?? 0}</div>
<div>资料引用:{counts?.documentRefs ?? 0}</div>
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
</div>
</DetailSideMeta>
<SectionPanel title="执行摘要" description="人、事、费、周报统一查看,帮助快速切回具体页面。">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">报销</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.formNumber} · {item.totalAmount}
</Link>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">差旅</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.requestNo} · {item.estimatedAmount}
</Link>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">项目周报</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
</Link>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">合同台账</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
{item.contractNo} · {item.counterpartyName}
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">交付记录</h3><div className="text-sm text-zinc-500">现场、验收、交接留痕</div></div>
<div className="space-y-3">
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
</select>
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
<TableBody>
{(deliveriesQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.summary}</div>
</TableCell>
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionPanel>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">涉密资料引用</h3><div className="text-sm text-zinc-500">只登记目录 / 链接 / 归档位置</div></div>
<div className="space-y-3">
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
<div className="grid gap-3 md:grid-cols-2">
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
</div>
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
</select>
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
</select>
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
<TableBody>
{(docsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
</TableCell>
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">项目周报关联</h3><div className="text-sm text-zinc-500">按项目查看每周进展</div></div>
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
<TableBody>
{(weeklyQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? "-"}</TableCell>
<TableCell>{item.weekStart ?? "-"}</TableCell>
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
<TableCell>{item.nextStepText || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</PageScaffold>
);
}