PMFlow/frontend/src/pages/project-board/ProjectBoardPage.tsx

684 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react";
import { aiPmoApi } from "@/api/aiPmo";
import { sstcApi, type AIRoutingConfig, type SSTCSignal, type StageKey } from "@/api/sstc";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ModelCapabilityMatrix } from "@/components/model-capability/ModelCapabilityMatrix";
import { BudgetControlPanel } from "@/components/budget/BudgetControlPanel";
import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader";
import { SSTCStageNav } from "@/components/sstc/SSTCStageNav";
import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace";
import { useToast } from "@/components/ui/toast";
import { formatProjectNameWithId } from "@/lib/display";
import { getApiErrorMessage } from "@/lib/apiError";
import { compactTitle } from "@/lib/title";
import { cn } from "@/lib/utils";
import CreateCharterDialog from "./components/CreateCharterDialog";
import ProjectSettingsDialog from "./components/ProjectSettingsDialog";
import {
DEFAULT_SIGNAL_FILTERS,
type SignalFilterState,
LIVE_REFRESH_MS,
dedupeWorkItemsBySemanticKey,
toReadableWorkItemSummary,
toReadableWorkItemTitle,
} from "./helpers";
export default function ProjectBoardPage() {
const SIGNAL_PAGE_SIZE = 10;
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [isMobileViewport, setIsMobileViewport] = useState(() =>
typeof window !== "undefined" ? window.innerWidth < 768 : false
);
const [activeStage, setActiveStage] = useState<StageKey>("signal");
const [selectedCharterId, setSelectedCharterId] = useState<string | null>(null);
const [createCharterOpen, setCreateCharterOpen] = useState(false);
const [newCharterTitle, setNewCharterTitle] = useState("");
const [newCharterRawText, setNewCharterRawText] = useState("");
const [selectedSignalIds, setSelectedSignalIds] = useState<string[]>([]);
const [charterSeedSignalId, setCharterSeedSignalId] = useState<string | null>(null);
const [signalSearch, setSignalSearch] = useState("");
const [showAllSignalOptions, setShowAllSignalOptions] = useState(false);
const [signalPage, setSignalPage] = useState(1);
const [signalFilters, setSignalFilters] = useState<SignalFilterState>(DEFAULT_SIGNAL_FILTERS);
const [settingsOpen, setSettingsOpen] = useState(false);
const [routingDraft, setRoutingDraft] = useState<AIRoutingConfig | null>(null);
const [overviewExpanded, setOverviewExpanded] = useState(true);
const deferredSignalSearch = useDeferredValue(signalSearch.trim());
const { data: boardData, isLoading, error } = useQuery({
queryKey: ["sstc-board-overview", id],
queryFn: () => sstcApi.getBoardOverview(id!),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: chain } = useQuery({
queryKey: ["sstc-chain", id],
queryFn: () => sstcApi.getChain(id!),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: signalsPayload } = useQuery({
queryKey: ["sstc-signals", id, signalPage, signalFilters],
queryFn: () =>
sstcApi.listSignals(id!, {
status: signalFilters.status || undefined,
keyword: signalFilters.keyword.trim() || undefined,
source: signalFilters.source || undefined,
priority: signalFilters.priority || undefined,
created_from: signalFilters.created_from || undefined,
created_to: signalFilters.created_to || undefined,
limit: SIGNAL_PAGE_SIZE,
offset: (signalPage - 1) * SIGNAL_PAGE_SIZE,
}),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const signals = signalsPayload?.items || [];
const signalTotal = Number(signalsPayload?.total || 0);
const signalTotalPages = Math.max(1, Math.ceil(signalTotal / SIGNAL_PAGE_SIZE));
const { data: signalPickerPayload } = useQuery({
queryKey: ["sstc-signals-picker", id],
queryFn: () =>
sstcApi.listSignals(id!, {
limit: 200,
offset: 0,
}),
enabled: !!id && createCharterOpen && showAllSignalOptions,
staleTime: 60_000,
});
const { data: charters = [] } = useQuery({
queryKey: ["sstc-charters", id],
queryFn: () => sstcApi.listCharters(id!),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: tasks = [] } = useQuery({
queryKey: ["sstc-task-board", id],
queryFn: () => sstcApi.getTaskBoard(id!),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: releases = [] } = useQuery({
queryKey: ["sstc-releases", id],
queryFn: () => sstcApi.listReleases(id!),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: reviews = [] } = useQuery({
queryKey: ["sstc-reviews", id],
queryFn: () => sstcApi.listReviews(id!),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: routingConfig, isLoading: routingConfigLoading } = useQuery({
queryKey: ["sstc-ai-routing-config", id],
queryFn: () => sstcApi.getAIRoutingConfig(id!),
enabled: !!id,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const numericProjectId = Number(id || 0);
const { data: pmoWorkItemsPayload } = useQuery({
queryKey: ["ai-pmo-work-items", id],
queryFn: () => aiPmoApi.listWorkItems({ projectId: numericProjectId, limit: 6 }),
enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: pmoRiskSnapshot } = useQuery({
queryKey: ["ai-pmo-cadence", "risk_review", id],
queryFn: () => aiPmoApi.getLatestCadence({ cadenceType: "risk_review", projectId: numericProjectId }),
enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
const { data: pmoDailySnapshot } = useQuery({
queryKey: ["ai-pmo-cadence", "daily_standup", id],
queryFn: () => aiPmoApi.getLatestCadence({ cadenceType: "daily_standup", projectId: numericProjectId }),
enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0,
refetchInterval: LIVE_REFRESH_MS,
refetchOnWindowFocus: true,
});
useEffect(() => {
if (routingConfig) {
setRoutingDraft(routingConfig);
}
}, [routingConfig]);
const updateRoutingConfigMutation = useMutation({
mutationFn: (payload: {
cold_start_enabled: boolean;
signal_auto_enter_threshold: number;
project_auto_create_threshold: number;
project_uncertain_threshold: number;
}) => sstcApi.updateAIRoutingConfig(id!, payload),
onSuccess: (data) => {
setRoutingDraft(data);
setSettingsOpen(false);
queryClient.invalidateQueries({ queryKey: ["sstc-ai-routing-config", id] });
refreshAll();
},
});
useEffect(() => {
if (!selectedCharterId && charters.length > 0) {
setSelectedCharterId(charters[0].id);
}
}, [charters, selectedCharterId]);
useEffect(() => {
if (typeof window === "undefined") return;
const syncViewport = () => setIsMobileViewport(window.innerWidth < 768);
syncViewport();
window.addEventListener("resize", syncViewport);
return () => window.removeEventListener("resize", syncViewport);
}, []);
useEffect(() => {
setSignalPage(1);
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
}, [id]);
const refreshAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["sstc-board-overview", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-chain", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-signals", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-signals-picker", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-charters", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-task-board", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-releases", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-reviews", id] }),
queryClient.invalidateQueries({ queryKey: ["sstc-governance", id] }),
queryClient.invalidateQueries({ queryKey: ["workloads"] }),
]);
};
const resetCreateCharterDraft = () => {
setNewCharterTitle("");
setNewCharterRawText("");
setSelectedSignalIds([]);
setCharterSeedSignalId(null);
setSignalSearch("");
setShowAllSignalOptions(false);
};
const handleCreateCharterDialogChange = (open: boolean) => {
setCreateCharterOpen(open);
if (!open && !createCharterMutation.isPending) {
resetCreateCharterDraft();
}
};
async function runCharterPrefillInBackground(charterId: string, rawText: string) {
try {
await sstcApi.aiPrefillCharter(
id!,
charterId,
rawText ? { raw_text: rawText } : {}
);
await refreshAll();
toast({ title: "宪章草稿已自动补全 6 模块", variant: "success" });
} catch (error) {
toast({
title: getApiErrorMessage(error, "AI 补全失败,请在宪章页手动重试"),
variant: "error",
});
}
}
const createCharterMutation = useMutation({
mutationFn: async () => {
const rawText = newCharterRawText.trim();
const charter = await sstcApi.createCharter(id!, {
title: compactTitle(newCharterTitle, 24),
content: rawText ? { raw_source_text: rawText } : undefined,
signal_ids: selectedSignalIds.map((v) => Number(v)),
skip_ai_prefill: true,
});
return { charter, rawText };
},
onSuccess: ({ charter, rawText }) => {
setActiveStage("charter");
setSelectedCharterId(charter.id);
setCreateCharterOpen(false);
resetCreateCharterDraft();
void refreshAll();
toast({
title: "宪章已创建,AI 正在后台补全 6 模块",
variant: "success",
});
void runCharterPrefillInBackground(charter.id, rawText);
},
onError: (error) => {
toast({
title: getApiErrorMessage(error, "创建宪章失败"),
variant: "error",
});
},
});
const stageNodes = boardData?.stages || [];
const overview = boardData?.overview;
const risks = boardData?.risks;
const selectedStageLocked = useMemo(
() => stageNodes.find((s) => s.key === activeStage)?.locked ?? false,
[stageNodes, activeStage]
);
const compactNewCharterTitle = compactTitle(newCharterTitle, 24);
const boardTitle = compactTitle(
formatProjectNameWithId({
id: overview?.project?.id || chain?.project?.id || id,
title: overview?.project?.title || chain?.project?.title || "项目看板",
}),
24
);
const pmoWorkItems = useMemo(
() => dedupeWorkItemsBySemanticKey(pmoWorkItemsPayload?.items || []),
[pmoWorkItemsPayload?.items]
);
const pendingManualWorkItemCount = useMemo(
() => pmoWorkItems.filter((item) => item.state === "waiting_manual").length,
[pmoWorkItems]
);
const riskSummary = (pmoRiskSnapshot?.summary || {}) as Record<string, number>;
const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record<string, unknown>;
const riskActions = Array.isArray(dailyPayload.focus_actions)
? (dailyPayload.focus_actions as string[]).slice(0, 2)
: [];
const currentSignalIds = useMemo(() => new Set(signals.map((signal) => signal.id)), [signals]);
const signalOptionPool = useMemo(() => {
const merged = new Map<string, SSTCSignal>();
signals.forEach((signal) => merged.set(signal.id, signal));
(signalPickerPayload?.items || []).forEach((signal) => merged.set(signal.id, signal));
return Array.from(merged.values());
}, [signals, signalPickerPayload?.items]);
const signalOptions = useMemo(() => {
const keyword = deferredSignalSearch.toLowerCase();
const scoped = signalOptionPool.filter((signal) => {
if (showAllSignalOptions) return true;
return (
currentSignalIds.has(signal.id) ||
signal.id === charterSeedSignalId ||
selectedSignalIds.includes(signal.id)
);
});
const filtered = keyword
? scoped.filter((signal) => {
const haystack = [
signal.title,
signal.content,
signal.proposer_name,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(keyword);
})
: scoped;
return filtered.sort((a, b) => {
const aPinned = Number(a.id === charterSeedSignalId || selectedSignalIds.includes(a.id));
const bPinned = Number(b.id === charterSeedSignalId || selectedSignalIds.includes(b.id));
return bPinned - aPinned;
});
}, [
charterSeedSignalId,
currentSignalIds,
deferredSignalSearch,
selectedSignalIds,
showAllSignalOptions,
signalOptionPool,
]);
useEffect(() => {
if (!createCharterOpen) return;
setSelectedSignalIds((prev) =>
prev.filter((sid) => {
const signal = signalOptionPool.find((item) => item.id === sid);
if (!signal) return false;
return signal.can_create_charter !== false;
})
);
}, [createCharterOpen, signalOptionPool]);
const toggleSignal = (sid: string) => {
const signal = signalOptionPool.find((item) => item.id === sid);
if (signal && signal.can_create_charter === false) return;
setSelectedSignalIds((prev) =>
prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid]
);
};
const handleSignalFiltersChange = (patch: Partial<SignalFilterState>) => {
setSignalPage(1);
setSignalFilters((prev) => ({ ...prev, ...patch }));
};
const resetSignalFilters = () => {
setSignalPage(1);
setSignalFilters(DEFAULT_SIGNAL_FILTERS);
};
const openCreateCharterDialog = () => {
resetCreateCharterDraft();
setCreateCharterOpen(true);
};
const openCreateCharterFromSignal = (signal: SSTCSignal) => {
if (signal.can_create_charter === false) {
toast({
title: signal.charter_lock_reason || "该信号已关联宪章,禁止重复新建",
variant: "error",
});
return;
}
const titleSource = signal.title || signal.content || "新需求";
setNewCharterTitle(compactTitle(titleSource, 24));
setNewCharterRawText("");
setSelectedSignalIds([signal.id]);
setCharterSeedSignalId(signal.id);
setSignalSearch("");
setShowAllSignalOptions(false);
setCreateCharterOpen(true);
};
return (
<div className="space-y-4 pb-24">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link to="/projects">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
{boardTitle}
</h1>
<p className="text-muted-foreground">SSTC 唯一事实链路 · 可视化操作中枢</p>
</div>
<Button className="ml-auto" onClick={openCreateCharterDialog}>
<Plus className="mr-2 h-4 w-4" />
新建宪章
</Button>
</div>
<Tabs defaultValue="sstc" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sstc">SSTC 看板</TabsTrigger>
<TabsTrigger value="capability">模型能力矩阵</TabsTrigger>
<TabsTrigger value="budget">预算管控</TabsTrigger>
</TabsList>
<TabsContent value="sstc" className="space-y-4">
{isLoading ? (
<p className="text-muted-foreground">加载中…</p>
) : error || !overview ? (
<Card>
<CardContent className="py-12 text-center text-destructive">
加载失败:{String(error)}
</CardContent>
</Card>
) : (
<>
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white/70 shadow-sm">
<button
type="button"
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left transition hover:bg-slate-50/80"
onClick={() => setOverviewExpanded((prev) => !prev)}
aria-expanded={overviewExpanded}
>
<div>
<div className="text-sm font-semibold text-slate-900">项目概览</div>
<p className="mt-1 text-sm text-muted-foreground">
汇总项目状态、团队负载、PMO 风险节奏与待处理工作项。
</p>
</div>
<span className="inline-flex items-center gap-1 text-sm font-medium text-slate-600">
{overviewExpanded ? "收起" : "展开"}
{overviewExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</span>
</button>
<div
className={cn(
"grid transition-all duration-300 ease-out",
overviewExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
>
<div className="overflow-hidden">
<div className="space-y-4 border-t border-slate-200/80 px-5 py-5">
<SSTCGlobalHeader
overview={overview}
onOpenAi={() => navigate("/ai-steward")}
onOpenSettings={() => setSettingsOpen(true)}
onOpenWorkloads={() => navigate("/workloads")}
onExport={() => {
const payload = JSON.stringify({ overview, chain, stageNodes, risks }, null, 2);
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `链路导出-${id}.json`;
a.click();
URL.revokeObjectURL(url);
}}
/>
<div className="grid gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardContent className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-slate-900">AI PMO 风险节奏</div>
<p className="mt-1 text-sm text-muted-foreground">
最新 cadence 快照已接入项目看板,风险、阻塞和待人工确认不再只存在旧队列里。
</p>
</div>
<Button variant="outline" size="sm" onClick={() => navigate(`/inbox?project_id=${id}&section=action_required`)}>
查看收件箱
</Button>
</div>
<div className="grid gap-3 md:grid-cols-4">
{[
{ label: "Blocked Tasks", value: Number(riskSummary.blocked_task_count || 0) },
{ label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) },
{ label: "风险通知", value: Number(riskSummary.unread_risk_notification_count || 0) },
{ label: "待确认工作项", value: pendingManualWorkItemCount },
].map((metric) => (
<div key={metric.label} className="rounded-xl bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.12em] text-slate-500">{metric.label}</div>
<div className="mt-2 text-2xl font-bold text-slate-900">{metric.value}</div>
</div>
))}
</div>
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前焦点动作</div>
<div className="mt-2 space-y-2">
{riskActions.length > 0 ? (
riskActions.map((action) => (
<div key={action} className="text-sm text-slate-700">
{action}
</div>
))
) : (
<div className="text-sm text-slate-500">当前暂无新增高风险节奏动作。</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 p-5">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-slate-900">AI PMO 工作项</div>
<Button variant="ghost" size="sm" onClick={() => navigate(`/inbox?project_id=${id}`)}>
全部查看
</Button>
</div>
{pmoWorkItems.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6">
<div className="text-sm text-slate-600">当前项目暂无 PMO 工作项。</div>
<div className="mt-2 text-xs text-slate-500">
建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" variant="outline" onClick={() => setActiveStage("task")}>
去任务阶段
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/inbox?project_id=${id}`)}>
打开消息台
</Button>
</div>
</div>
) : (
pmoWorkItems.map((item) => (
<button
key={item.id}
type="button"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition hover:bg-white"
onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)}
>
<div className="flex items-start justify-between gap-3">
<div className="line-clamp-2 text-sm font-medium text-slate-900">{toReadableWorkItemTitle(item)}</div>
{item.state === "waiting_manual" && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />}
</div>
<div className="mt-2 text-xs text-slate-500">{item.stage_text || item.state}</div>
<div className="mt-2 line-clamp-2 text-xs text-slate-600">{toReadableWorkItemSummary(item)}</div>
</button>
))
)}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
<SSTCStageNav
stages={stageNodes}
activeStage={activeStage}
signalPreview={chain?.signals?.slice(0, 6) || signals.slice(0, 6)}
onChange={setActiveStage}
/>
<SSTCWorkspace
projectId={id!}
activeStage={activeStage}
isMobileViewport={isMobileViewport}
charterReadOnly={isMobileViewport}
stages={stageNodes}
signals={signals}
signalFilters={signalFilters}
charters={charters}
tasks={tasks}
releases={releases}
reviews={reviews}
selectedCharterId={selectedCharterId}
signalPage={signalPage}
signalTotal={signalTotal}
signalPageSize={SIGNAL_PAGE_SIZE}
onSignalPageChange={(nextPage) =>
setSignalPage(Math.min(Math.max(1, nextPage), signalTotalPages))
}
onSelectCharter={setSelectedCharterId}
onCreateCharterFromSignal={openCreateCharterFromSignal}
onSignalFiltersChange={handleSignalFiltersChange}
onResetSignalFilters={resetSignalFilters}
onRefresh={refreshAll}
/>
{selectedStageLocked && (
<p className="text-xs text-amber-700">
当前阶段处于锁定态,仅可查看;请先完成上游阶段。
</p>
)}
</>
)}
</TabsContent>
<TabsContent value="capability">
<ModelCapabilityMatrix projectId={id!} />
</TabsContent>
<TabsContent value="budget">
<BudgetControlPanel projectId={id!} />
</TabsContent>
</Tabs>
<CreateCharterDialog
open={createCharterOpen}
onOpenChange={handleCreateCharterDialogChange}
newCharterTitle={newCharterTitle}
setNewCharterTitle={setNewCharterTitle}
compactNewCharterTitle={compactNewCharterTitle}
newCharterRawText={newCharterRawText}
setNewCharterRawText={setNewCharterRawText}
selectedSignalIds={selectedSignalIds}
charterSeedSignalId={charterSeedSignalId}
showAllSignalOptions={showAllSignalOptions}
setShowAllSignalOptions={setShowAllSignalOptions}
signalSearch={signalSearch}
setSignalSearch={setSignalSearch}
signalPickerLoaded={Boolean(signalPickerPayload)}
signalOptions={signalOptions}
onToggleSignal={toggleSignal}
onCancel={() => setCreateCharterOpen(false)}
onCreate={() => createCharterMutation.mutate()}
isCreating={createCharterMutation.isPending}
canCreate={Boolean(compactNewCharterTitle) && selectedSignalIds.length > 0}
/>
<ProjectSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
routingDraft={routingDraft}
setRoutingDraft={setRoutingDraft}
routingConfigLoading={routingConfigLoading}
isSaving={updateRoutingConfigMutation.isPending}
onCancel={() => {
setSettingsOpen(false);
setRoutingDraft(routingConfig || null);
}}
onSave={(payload) => {
updateRoutingConfigMutation.mutate(payload);
}}
/>
</div>
);
}