feat(workbench): complete company view — health trend, major events, milestones, team, synergy + dashboard click-through
This commit is contained in:
@@ -18,16 +18,25 @@ from app.schemas.company import (
|
||||
AgreementBrief,
|
||||
BoardMeetingBrief,
|
||||
HealthScoreBrief,
|
||||
HealthScoreHistoryPoint,
|
||||
MajorEventBrief,
|
||||
MilestoneBrief,
|
||||
ReportBrief,
|
||||
RiskBrief,
|
||||
SynergyBrief,
|
||||
TeamMemberBrief,
|
||||
WeakSignalBrief,
|
||||
)
|
||||
from app.models.agreement import InvestmentAgreement
|
||||
from app.models.board import BoardMeeting
|
||||
from app.models.financial_data import FinancialData
|
||||
from app.models.health_score import HealthScore
|
||||
from app.models.major_event import MajorEvent
|
||||
from app.models.milestone import MilestoneTree
|
||||
from app.models.report import MonthlyReport
|
||||
from app.models.risk import RiskEvent
|
||||
from app.models.synergy import SynergyOpportunity
|
||||
from app.models.talent import TeamMember
|
||||
from app.models.weak_signal import WeakSignal
|
||||
|
||||
router = APIRouter(prefix="/companies", tags=["companies"])
|
||||
@@ -224,14 +233,102 @@ async def get_company_detail(
|
||||
latest_fin = latest_fin_result.scalar_one_or_none()
|
||||
latest_financial = latest_fin.data_json if latest_fin else None
|
||||
|
||||
# 健康度历史趋势(最近 12 条)
|
||||
history_result = await db.execute(
|
||||
select(HealthScore)
|
||||
.where(HealthScore.company_id == company_id)
|
||||
.order_by(HealthScore.calculated_at.asc())
|
||||
.limit(12)
|
||||
)
|
||||
history_scores = history_result.scalars().all()
|
||||
health_score_history = [
|
||||
HealthScoreHistoryPoint(
|
||||
period=s.calculated_at.strftime("%Y-%m"),
|
||||
total_score=s.total_score,
|
||||
calculated_at=s.calculated_at,
|
||||
)
|
||||
for s in history_scores
|
||||
]
|
||||
|
||||
# 重大事项(最近 10 条)
|
||||
events_result = await db.execute(
|
||||
select(MajorEvent)
|
||||
.where(MajorEvent.company_id == company_id)
|
||||
.order_by(MajorEvent.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
events = events_result.scalars().all()
|
||||
major_events = [
|
||||
MajorEventBrief(
|
||||
id=e.id, event_type=e.event_type, title=e.title,
|
||||
description=e.description, severity=e.severity,
|
||||
status=e.status, occurred_at=e.occurred_at,
|
||||
) for e in events
|
||||
]
|
||||
|
||||
# 里程碑
|
||||
milestone_result = await db.execute(
|
||||
select(MilestoneTree)
|
||||
.where(MilestoneTree.company_id == company_id)
|
||||
.order_by(MilestoneTree.target_date.desc().nulls_last())
|
||||
.limit(20)
|
||||
)
|
||||
milestones = milestone_result.scalars().all()
|
||||
milestone_briefs = [
|
||||
MilestoneBrief(
|
||||
id=m.id, name=m.name, status=m.status, is_current=m.is_current,
|
||||
target_date=m.target_date, actual_date=m.actual_date,
|
||||
description=m.description,
|
||||
) for m in milestones
|
||||
]
|
||||
|
||||
# 团队成员
|
||||
team_result = await db.execute(
|
||||
select(TeamMember)
|
||||
.where(TeamMember.company_id == company_id)
|
||||
.order_by(TeamMember.is_key_person.desc(), TeamMember.joined_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
team_members_data = team_result.scalars().all()
|
||||
team_members = [
|
||||
TeamMemberBrief(
|
||||
id=t.id, name=t.name, role=t.role, is_key_person=t.is_key_person,
|
||||
stability_score=t.stability_score, joined_at=t.joined_at,
|
||||
) for t in team_members_data
|
||||
]
|
||||
|
||||
# 协同机会(涉及该企业的)
|
||||
synergy_result = await db.execute(
|
||||
select(SynergyOpportunity)
|
||||
.where(
|
||||
(SynergyOpportunity.company_a_id == company_id) |
|
||||
(SynergyOpportunity.company_b_id == company_id)
|
||||
)
|
||||
.order_by(SynergyOpportunity.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
synergies = synergy_result.scalars().all()
|
||||
synergy_briefs = [
|
||||
SynergyBrief(
|
||||
id=s.id, type=s.type, title=s.title,
|
||||
description=s.description, status=s.status,
|
||||
match_reason=s.match_reason,
|
||||
) for s in synergies
|
||||
]
|
||||
|
||||
return success(data=CompanyDetailResponse(
|
||||
company=CompanyResponse.model_validate(company, from_attributes=True),
|
||||
health_score=health_brief,
|
||||
health_score_history=health_score_history,
|
||||
recent_reports=report_briefs,
|
||||
open_risks=risk_briefs,
|
||||
recent_weak_signals=signal_briefs,
|
||||
active_agreements=agreement_briefs,
|
||||
recent_board_meetings=meeting_briefs,
|
||||
major_events=major_events,
|
||||
milestones=milestone_briefs,
|
||||
team_members=team_members,
|
||||
synergy_opportunities=synergy_briefs,
|
||||
financial_data_count=fin_count,
|
||||
latest_financial=latest_financial,
|
||||
))
|
||||
|
||||
@@ -130,15 +130,74 @@ class BoardMeetingBrief(BaseModel):
|
||||
meeting_at: datetime | None = None
|
||||
|
||||
|
||||
class MajorEventBrief(BaseModel):
|
||||
"""重大事项摘要。"""
|
||||
|
||||
id: str
|
||||
event_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
severity: str = "medium"
|
||||
status: str = "identified"
|
||||
occurred_at: datetime | None = None
|
||||
|
||||
|
||||
class MilestoneBrief(BaseModel):
|
||||
"""里程碑摘要。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
status: str = "planned"
|
||||
is_current: bool = False
|
||||
target_date: datetime | None = None
|
||||
actual_date: datetime | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class TeamMemberBrief(BaseModel):
|
||||
"""团队成员摘要。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
role: str | None = None
|
||||
is_key_person: bool = False
|
||||
stability_score: float | None = None
|
||||
joined_at: datetime | None = None
|
||||
|
||||
|
||||
class SynergyBrief(BaseModel):
|
||||
"""协同机会摘要。"""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: str = "discovered"
|
||||
match_reason: str | None = None
|
||||
|
||||
|
||||
class HealthScoreHistoryPoint(BaseModel):
|
||||
"""健康度历史数据点。"""
|
||||
|
||||
period: str
|
||||
total_score: float
|
||||
calculated_at: datetime
|
||||
|
||||
|
||||
class CompanyDetailResponse(BaseModel):
|
||||
"""企业详情聚合响应 — 工作台使用。"""
|
||||
|
||||
company: CompanyResponse
|
||||
health_score: HealthScoreBrief | None = None
|
||||
health_score_history: list[HealthScoreHistoryPoint] = []
|
||||
recent_reports: list[ReportBrief] = []
|
||||
open_risks: list[RiskBrief] = []
|
||||
recent_weak_signals: list[WeakSignalBrief] = []
|
||||
active_agreements: list[AgreementBrief] = []
|
||||
recent_board_meetings: list[BoardMeetingBrief] = []
|
||||
major_events: list[MajorEventBrief] = []
|
||||
milestones: list[MilestoneBrief] = []
|
||||
team_members: list[TeamMemberBrief] = []
|
||||
synergy_opportunities: list[SynergyBrief] = []
|
||||
financial_data_count: int = 0
|
||||
latest_financial: dict | None = None
|
||||
|
||||
@@ -10,21 +10,75 @@ import { HighlightsPanel } from "@/components/workbench/HighlightsPanel";
|
||||
import { WorkModeSwitcher, type WorkMode } from "@/components/shared/WorkModeSwitcher";
|
||||
import { InsightRail } from "@/components/shared/InsightRail";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { AlertTriangle, FileText, ClipboardList, ScrollText, Network, DollarSign, Activity, Users, Bot } from "lucide-react";
|
||||
import { AlertTriangle, FileText, ClipboardList, ScrollText, Network, DollarSign, Activity, Users, Bot, Flag, Calendar, TrendingUp } from "lucide-react";
|
||||
|
||||
/** 企业详情数据结构。 */
|
||||
interface CompanyDetail {
|
||||
company: { id: string; name: string; industry?: string };
|
||||
health_score: HealthScore | null;
|
||||
health_score_history: HealthScoreHistoryPoint[];
|
||||
recent_reports: ReportItem[];
|
||||
open_risks: RiskItem[];
|
||||
recent_weak_signals: WeakSignalItem[];
|
||||
active_agreements: AgreementItem[];
|
||||
recent_board_meetings: BoardMeetingItem[];
|
||||
major_events: MajorEventItem[];
|
||||
milestones: MilestoneItem[];
|
||||
team_members: TeamMemberItem[];
|
||||
synergy_opportunities: SynergyItem[];
|
||||
latest_financial?: Record<string, unknown>;
|
||||
financial_data_count?: number;
|
||||
}
|
||||
|
||||
/** 健康度历史数据点。 */
|
||||
interface HealthScoreHistoryPoint {
|
||||
period: string;
|
||||
total_score: number;
|
||||
calculated_at: string;
|
||||
}
|
||||
|
||||
/** 重大事项项。 */
|
||||
interface MajorEventItem {
|
||||
id: string;
|
||||
event_type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
occurred_at?: string;
|
||||
}
|
||||
|
||||
/** 里程碑项。 */
|
||||
interface MilestoneItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
is_current: boolean;
|
||||
target_date?: string;
|
||||
actual_date?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 团队成员项。 */
|
||||
interface TeamMemberItem {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
is_key_person: boolean;
|
||||
stability_score?: number;
|
||||
joined_at?: string;
|
||||
}
|
||||
|
||||
/** 协同机会项。 */
|
||||
interface SynergyItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
match_reason?: string;
|
||||
}
|
||||
|
||||
/** 健康度评分结构。 */
|
||||
type HealthScore = {
|
||||
total_score: number;
|
||||
@@ -105,7 +159,7 @@ export default function WorkbenchPage() {
|
||||
return <EmptyState title="未找到企业信息" />;
|
||||
}
|
||||
|
||||
const { company, health_score, recent_reports, open_risks, recent_weak_signals, active_agreements, recent_board_meetings } = detail;
|
||||
const { company, health_score, health_score_history, recent_reports, open_risks, recent_weak_signals, active_agreements, recent_board_meetings, major_events, milestones, team_members, synergy_opportunities } = detail;
|
||||
|
||||
const highRisksCount = (open_risks || []).filter((r) => r.severity === "high" || r.severity === "critical").length;
|
||||
const pendingTasksCount = (recent_reports || []).filter((r) => r.status === "pending" || r.status === "draft").length;
|
||||
@@ -137,13 +191,14 @@ export default function WorkbenchPage() {
|
||||
{activeTab === "overview" && <OverviewTab detail={detail} />}
|
||||
{activeTab === "financial" && <FinancialTab detail={detail} />}
|
||||
{activeTab === "operational" && <OperationalTab detail={detail} />}
|
||||
{activeTab === "org" && <OrgTab />}
|
||||
{activeTab === "org" && <OrgTab teamMembers={team_members} />}
|
||||
{activeTab === "ai" && <AITab detail={detail} />}
|
||||
{activeTab === "risk" && <RiskTab risks={open_risks} signals={recent_weak_signals} />}
|
||||
{activeTab === "events" && <EventsTab events={major_events} milestones={milestones} />}
|
||||
{activeTab === "reports" && <ReportsTab reports={recent_reports} />}
|
||||
{activeTab === "board" && <BoardTab meetings={recent_board_meetings} />}
|
||||
{activeTab === "agreements" && <AgreementsTab agreements={active_agreements} />}
|
||||
{activeTab === "synergy" && <SynergyTab />}
|
||||
{activeTab === "synergy" && <SynergyTab synergies={synergy_opportunities} />}
|
||||
</TabPanel>
|
||||
</div>
|
||||
|
||||
@@ -156,24 +211,79 @@ export default function WorkbenchPage() {
|
||||
|
||||
/** 概览 Tab — 健康度雷达图 + 维度详情。 */
|
||||
function OverviewTab({ detail }: { detail: CompanyDetail }) {
|
||||
const { health_score } = detail;
|
||||
const { health_score, health_score_history } = detail;
|
||||
if (!health_score) {
|
||||
return <EmptyState title="暂无健康度评分数据" />;
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-medium">健康度雷达图</h3>
|
||||
<HealthRadar scores={health_score} dimensions={DIMENSIONS_14} size={320} />
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-medium">健康度雷达图</h3>
|
||||
<HealthRadar scores={health_score} dimensions={DIMENSIONS_14} size={320} />
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-medium">维度详情</h3>
|
||||
<HealthDimensionDetail scores={health_score} dimensions={DIMENSIONS_14_DETAIL} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 健康度趋势图 */}
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-medium">维度详情</h3>
|
||||
<HealthDimensionDetail scores={health_score} dimensions={DIMENSIONS_14_DETAIL} />
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">健康度趋势</h3>
|
||||
</div>
|
||||
{health_score_history && health_score_history.length > 0 ? (
|
||||
<HealthTrendMini data={health_score_history} />
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">暂无历史趋势数据</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 健康度趋势迷你折线图。 */
|
||||
function HealthTrendMini({ data }: { data: HealthScoreHistoryPoint[] }) {
|
||||
const width = 500;
|
||||
const height = 180;
|
||||
const padding = { top: 15, right: 15, bottom: 30, left: 35 };
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
||||
const periods = data.map((d) => d.period);
|
||||
const scores = data.map((d) => d.total_score);
|
||||
const xStep = periods.length > 1 ? chartW / (periods.length - 1) : 0;
|
||||
const yScale = (val: number) => chartH - (val / 100) * chartH;
|
||||
|
||||
const linePath = scores
|
||||
.map((s, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(s)}`)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} role="img" aria-label="健康度趋势图" className="mt-2">
|
||||
{[0, 25, 50, 75, 100].map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={padding.left} y1={padding.top + yScale(v)}
|
||||
x2={width - padding.right} y2={padding.top + yScale(v)}
|
||||
stroke="var(--border)" strokeWidth={1} strokeDasharray={v === 0 ? "none" : "2,2"}
|
||||
/>
|
||||
<text x={padding.left - 6} y={padding.top + yScale(v) + 4} textAnchor="end" className="text-[10px] fill-muted-foreground">{v}</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={linePath} fill="none" stroke="var(--investor-primary)" strokeWidth={2} />
|
||||
{scores.map((s, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={padding.left + i * xStep} cy={padding.top + yScale(s)} r={3} fill="var(--investor-primary)" />
|
||||
<text x={padding.left + i * xStep} y={padding.top + yScale(s) - 8} textAnchor="middle" className="text-[9px] font-medium fill-foreground">{s.toFixed(0)}</text>
|
||||
<text x={padding.left + i * xStep} y={height - padding.bottom + 14} textAnchor="middle" className="text-[9px] fill-muted-foreground">{periods[i]}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 财务 Tab。 */
|
||||
function FinancialTab({ detail }: { detail: CompanyDetail }) {
|
||||
const { latest_financial, financial_data_count } = detail;
|
||||
@@ -223,14 +333,40 @@ function OperationalTab({ detail }: { detail: CompanyDetail }) {
|
||||
}
|
||||
|
||||
/** 组织 Tab。 */
|
||||
function OrgTab() {
|
||||
function OrgTab({ teamMembers }: { teamMembers: TeamMemberItem[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">组织面板</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">核心团队 ({teamMembers?.length || 0})</h3>
|
||||
</div>
|
||||
{teamMembers && teamMembers.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{teamMembers.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{m.name}</span>
|
||||
{m.is_key_person && (
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700">核心</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{m.role && <span>{m.role}</span>}
|
||||
{m.stability_score != null && (
|
||||
<span>稳定性: {(m.stability_score * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
{m.joined_at && (
|
||||
<span>入职: {new Date(m.joined_at).toLocaleDateString("zh-CN")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无团队成员数据" />
|
||||
)}
|
||||
</div>
|
||||
<EmptyState title="组织数据待月报结构化后展示" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -383,15 +519,130 @@ function AgreementsTab({ agreements }: { agreements: AgreementItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 重大事项 Tab。 */
|
||||
function EventsTab({ events, milestones }: { events: MajorEventItem[]; milestones: MilestoneItem[] }) {
|
||||
const severityColor = (s: string) =>
|
||||
s === "critical" ? "bg-rose-100 text-rose-700" :
|
||||
s === "high" ? "bg-amber-100 text-amber-700" :
|
||||
s === "medium" ? "bg-blue-100 text-blue-700" :
|
||||
"bg-muted text-muted-foreground";
|
||||
|
||||
const statusLabel = (s: string) =>
|
||||
s === "identified" ? "已识别" :
|
||||
s === "confirmed" ? "已确认" :
|
||||
s === "addressed" ? "已处理" : s;
|
||||
|
||||
const milestoneStatusColor = (s: string) =>
|
||||
s === "completed" ? "bg-emerald-100 text-emerald-700" :
|
||||
s === "in_progress" ? "bg-blue-100 text-blue-700" :
|
||||
s === "abandoned" ? "bg-muted text-muted-foreground line-through" :
|
||||
"bg-amber-100 text-amber-700";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 重大事项 */}
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flag size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">重大事项 ({events?.length || 0})</h3>
|
||||
</div>
|
||||
{events && events.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="rounded-md border border-[var(--border)] px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{e.title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs ${severityColor(e.severity)}`}>{e.severity}</span>
|
||||
<span className="text-xs text-muted-foreground">{statusLabel(e.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{e.description && <p className="mt-1 text-xs text-muted-foreground">{e.description}</p>}
|
||||
{e.occurred_at && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">发生时间: {new Date(e.occurred_at).toLocaleDateString("zh-CN")}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无重大事项" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 里程碑 */}
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">里程碑 ({milestones?.length || 0})</h3>
|
||||
</div>
|
||||
{milestones && milestones.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{milestones.map((m) => (
|
||||
<div key={m.id} className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{m.is_current && <span className="h-2 w-2 rounded-full bg-emerald-500" />}
|
||||
<span className={`text-sm font-medium ${m.status === "abandoned" ? "line-through text-muted-foreground" : ""}`}>{m.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{m.target_date && <span>目标: {new Date(m.target_date).toLocaleDateString("zh-CN")}</span>}
|
||||
{m.actual_date && <span className="text-emerald-600">完成: {new Date(m.actual_date).toLocaleDateString("zh-CN")}</span>}
|
||||
<span className={`rounded px-1.5 py-0.5 ${milestoneStatusColor(m.status)}`}>{m.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无里程碑数据" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 协同 Tab。 */
|
||||
function SynergyTab() {
|
||||
function SynergyTab({ synergies }: { synergies: SynergyItem[] }) {
|
||||
const typeLabel = (t: string) =>
|
||||
t === "customer" ? "客户" :
|
||||
t === "talent" ? "人才" :
|
||||
t === "funding" ? "融资" :
|
||||
t === "supply_chain" ? "供应链" :
|
||||
t === "tech" ? "技术" : t;
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === "completed" ? "bg-emerald-100 text-emerald-700" :
|
||||
s === "executing" ? "bg-blue-100 text-blue-700" :
|
||||
s === "authorized" ? "bg-indigo-100 text-indigo-700" :
|
||||
s === "confirmed" ? "bg-amber-100 text-amber-700" :
|
||||
s === "declined" ? "bg-rose-100 text-rose-700" :
|
||||
"bg-muted text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">协同机会</h3>
|
||||
<h3 className="text-sm font-medium">协同机会 ({synergies?.length || 0})</h3>
|
||||
</div>
|
||||
<EmptyState title="请前往协同中心查看" />
|
||||
{synergies && synergies.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{synergies.map((s) => (
|
||||
<div key={s.id} className="rounded-md border border-[var(--border)] px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{s.title}</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">{typeLabel(s.type)}</span>
|
||||
</div>
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs ${statusColor(s.status)}`}>{s.status}</span>
|
||||
</div>
|
||||
{s.description && <p className="mt-1 text-xs text-muted-foreground">{s.description}</p>}
|
||||
{s.match_reason && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">匹配理由: {s.match_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无协同机会" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
||||
import { Building2, Heart, AlertTriangle, FileText, TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getDashboardSummary, type DashboardSummary } from "@/lib/dashboard";
|
||||
import { listCompanies, type Company } from "@/lib/companies";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
|
||||
@@ -15,19 +14,13 @@ import { HealthHeatmap, HealthTrends } from "@/components/dashboard/HealthHeatma
|
||||
*/
|
||||
export default function InvestorDashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [summaryResp, companiesResp] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
listCompanies({ page_size: 100 }),
|
||||
]);
|
||||
if (summaryResp.data) setSummary(summaryResp.data);
|
||||
if (companiesResp.data?.items) setCompanies(companiesResp.data.items);
|
||||
const resp = await getDashboardSummary();
|
||||
if (resp.data) setSummary(resp.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
@@ -83,35 +76,11 @@ export default function InvestorDashboardPage() {
|
||||
},
|
||||
];
|
||||
|
||||
// 筛选后的评分列表
|
||||
const filteredScores = selectedCompanyId
|
||||
? summary?.recent_scores.filter((s) => s.company_id === selectedCompanyId) ?? []
|
||||
: summary?.recent_scores ?? [];
|
||||
|
||||
// 选中的企业名称
|
||||
const selectedCompany = companies.find((c) => c.id === selectedCompanyId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">投资机构驾驶舱</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{selectedCompany ? `${selectedCompany.name} — 企业健康度概览` : "Portfolio 全局健康度与风险概览"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 企业筛选器 */}
|
||||
<select
|
||||
value={selectedCompanyId}
|
||||
onChange={(e) => setSelectedCompanyId(e.target.value)}
|
||||
className="rounded-md border bg-white px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--investor-primary)]"
|
||||
>
|
||||
<option value="">全部企业</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">投资机构驾驶舱</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Portfolio 全局健康度与风险概览</p>
|
||||
</div>
|
||||
|
||||
{/* KPI 卡片 */}
|
||||
@@ -144,10 +113,14 @@ export default function InvestorDashboardPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{filteredScores.length > 0 ? (
|
||||
{summary && summary.recent_scores.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{filteredScores.map((score) => (
|
||||
<div key={score.id} className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0">
|
||||
{summary.recent_scores.map((score) => (
|
||||
<Link
|
||||
key={score.id}
|
||||
href={`/companies/${score.company_id}/workbench`}
|
||||
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0 transition-colors hover:bg-muted/30 rounded-md px-2 -mx-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<HealthScoreBadge score={Math.round(score.total_score)} />
|
||||
<div>
|
||||
@@ -175,7 +148,7 @@ export default function InvestorDashboardPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -187,10 +160,10 @@ export default function InvestorDashboardPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold">健康度热力图</h2>
|
||||
<HealthHeatmap companyId={selectedCompanyId || undefined} />
|
||||
<HealthHeatmap />
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-5 shadow-sm">
|
||||
<HealthTrends companyId={selectedCompanyId || undefined} />
|
||||
<HealthTrends />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {type ReactNode } from "react";
|
||||
import {
|
||||
LayoutDashboard, DollarSign, Activity, Users, Bot,
|
||||
AlertTriangle, FileText, ClipboardList, ScrollText, Network,
|
||||
AlertTriangle, FileText, ClipboardList, ScrollText, Network, Flag,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 工作台 Tab 定义。 */
|
||||
@@ -14,6 +14,7 @@ const TABS = [
|
||||
{ id: "org", label: "组织", icon: Users },
|
||||
{ id: "ai", label: "AI+专项", icon: Bot },
|
||||
{ id: "risk", label: "风险", icon: AlertTriangle },
|
||||
{ id: "events", label: "重大事项", icon: Flag },
|
||||
{ id: "reports", label: "月报", icon: FileText },
|
||||
{ id: "board", label: "董事会", icon: ClipboardList },
|
||||
{ id: "agreements", label: "协议", icon: ScrollText },
|
||||
|
||||
Reference in New Issue
Block a user