Without this the bot PAT 403s on POST /orgs/{org}/repos, which is
the single most important operation — creating new project repos
inside the workspace's Gitea org.
Made-with: Cursor
706 lines
26 KiB
TypeScript
706 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||
import { AtlasChat } from "@/components/AtlasChat";
|
||
import { useRouter, useParams } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { ArrowUpDown, Filter, LayoutPanelLeft, Search } from "lucide-react";
|
||
import { JM, JV } from "@/components/project-creation/modal-theme";
|
||
import {
|
||
type ChatContextRef,
|
||
contextRefKey,
|
||
} from "@/lib/chat-context-refs";
|
||
|
||
const DISCOVERY_PHASES = [
|
||
"big_picture",
|
||
"users_personas",
|
||
"features_scope",
|
||
"business_model",
|
||
"screens_data",
|
||
"risks_questions",
|
||
] as const;
|
||
|
||
const PHASE_DISPLAY: Record<string, string> = {
|
||
big_picture: "Big picture",
|
||
users_personas: "Users & personas",
|
||
features_scope: "Features & scope",
|
||
business_model: "Business model",
|
||
screens_data: "Screens & data",
|
||
risks_questions: "Risks & questions",
|
||
};
|
||
|
||
// Maps discovery phases → the PRD sections they populate
|
||
const PRD_SECTIONS: { label: string; phase: string | null }[] = [
|
||
{ label: "Executive Summary", phase: "big_picture" },
|
||
{ label: "Problem Statement", phase: "big_picture" },
|
||
{ label: "Vision & Success Metrics", phase: "big_picture" },
|
||
{ label: "Users & Personas", phase: "users_personas" },
|
||
{ label: "User Flows", phase: "users_personas" },
|
||
{ label: "Feature Requirements", phase: "features_scope" },
|
||
{ label: "Screen Specs", phase: "features_scope" },
|
||
{ label: "Business Model", phase: "business_model" },
|
||
{ label: "Integrations & Dependencies", phase: "screens_data" },
|
||
{ label: "Non-Functional Reqs", phase: "features_scope" },
|
||
{ label: "Risks & Mitigations", phase: "risks_questions" },
|
||
{ label: "Open Questions", phase: "risks_questions" },
|
||
];
|
||
|
||
type SidebarTab = "tasks" | "phases";
|
||
type GroupBy = "none" | "phase" | "status";
|
||
|
||
function sectionDone(
|
||
phase: string | null,
|
||
savedPhaseIds: Set<string>,
|
||
allDone: boolean
|
||
): boolean {
|
||
return phase === null ? allDone : savedPhaseIds.has(phase);
|
||
}
|
||
|
||
interface FreshIdeaMainProps {
|
||
projectId: string;
|
||
projectName: string;
|
||
}
|
||
|
||
export function FreshIdeaMain({ projectId, projectName }: FreshIdeaMainProps) {
|
||
const router = useRouter();
|
||
const params = useParams();
|
||
const workspace = params?.workspace as string;
|
||
|
||
const [savedPhaseIds, setSavedPhaseIds] = useState<Set<string>>(new Set());
|
||
const [allDone, setAllDone] = useState(false);
|
||
const [prdLoading, setPrdLoading] = useState(false);
|
||
const [dismissed, setDismissed] = useState(false);
|
||
const [hasPrd, setHasPrd] = useState(false);
|
||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("tasks");
|
||
const [sectionSearch, setSectionSearch] = useState("");
|
||
const [phaseScope, setPhaseScope] = useState<string>("all");
|
||
const [groupBy, setGroupBy] = useState<GroupBy>("none");
|
||
const [pendingOnly, setPendingOnly] = useState(false);
|
||
const [sortAlpha, setSortAlpha] = useState(false);
|
||
const [chatContextRefs, setChatContextRefs] = useState<ChatContextRef[]>([]);
|
||
|
||
const addSectionToChat = useCallback((label: string, phase: string | null) => {
|
||
setChatContextRefs(prev => {
|
||
const next: ChatContextRef = { kind: "section", label, phaseId: phase };
|
||
const k = contextRefKey(next);
|
||
if (prev.some(r => contextRefKey(r) === k)) return prev;
|
||
return [...prev, next];
|
||
});
|
||
}, []);
|
||
|
||
const addPhaseToChat = useCallback((phaseId: string, label: string) => {
|
||
setChatContextRefs(prev => {
|
||
const next: ChatContextRef = { kind: "phase", phaseId, label };
|
||
const k = contextRefKey(next);
|
||
if (prev.some(r => contextRefKey(r) === k)) return prev;
|
||
return [...prev, next];
|
||
});
|
||
}, []);
|
||
|
||
const removeChatContextRef = useCallback((key: string) => {
|
||
setChatContextRefs(prev => prev.filter(r => contextRefKey(r) !== key));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Check if PRD already exists on the project
|
||
fetch(`/api/projects/${projectId}`)
|
||
.then(r => r.json())
|
||
.then(d => { if (d.project?.prd) setHasPrd(true); })
|
||
.catch(() => {});
|
||
|
||
const poll = () => {
|
||
fetch(`/api/projects/${projectId}/save-phase`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
const ids = new Set<string>((d.phases ?? []).map((p: { phase: string }) => p.phase));
|
||
setSavedPhaseIds(ids);
|
||
const done = DISCOVERY_PHASES.every(id => ids.has(id));
|
||
setAllDone(done);
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
poll();
|
||
const interval = setInterval(poll, 8_000);
|
||
return () => clearInterval(interval);
|
||
}, [projectId]);
|
||
|
||
const handleGeneratePRD = async () => {
|
||
if (prdLoading) return;
|
||
setPrdLoading(true);
|
||
try {
|
||
router.push(`/${workspace}/project/${projectId}/tasks`);
|
||
} finally {
|
||
setPrdLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleMVP = () => {
|
||
router.push(`/${workspace}/project/${projectId}/mvp-setup/launch`);
|
||
};
|
||
|
||
// PRD exists — show a thin notice bar at the top, then keep the chat fully accessible
|
||
|
||
const completedSections = PRD_SECTIONS.filter(({ phase }) =>
|
||
phase === null ? allDone : savedPhaseIds.has(phase)
|
||
).length;
|
||
const totalSections = PRD_SECTIONS.length;
|
||
|
||
const filteredSections = useMemo(() => {
|
||
const q = sectionSearch.trim().toLowerCase();
|
||
let rows = PRD_SECTIONS.map((s, index) => ({ ...s, index }));
|
||
if (q) {
|
||
rows = rows.filter(r => r.label.toLowerCase().includes(q));
|
||
}
|
||
if (phaseScope !== "all") {
|
||
rows = rows.filter(r => r.phase === phaseScope);
|
||
}
|
||
if (pendingOnly) {
|
||
rows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
|
||
}
|
||
if (sortAlpha) {
|
||
rows = [...rows].sort((a, b) => a.label.localeCompare(b.label));
|
||
} else {
|
||
rows = [...rows].sort((a, b) => a.index - b.index);
|
||
}
|
||
return rows;
|
||
}, [sectionSearch, phaseScope, pendingOnly, sortAlpha, savedPhaseIds, allDone]);
|
||
|
||
const effectiveGroupBy: GroupBy = sidebarTab === "phases" ? "phase" : groupBy;
|
||
|
||
return (
|
||
<div style={{
|
||
height: "100%", display: "flex", flexDirection: "row", overflow: "hidden",
|
||
fontFamily: JM.fontSans,
|
||
}}>
|
||
|
||
{/* ── Left: Atlas chat (Justine describe column) ── */}
|
||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
|
||
|
||
{hasPrd && (
|
||
<div style={{
|
||
background: JM.primaryGradient,
|
||
boxShadow: JM.primaryShadow,
|
||
padding: "10px 20px",
|
||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||
gap: 16, flexShrink: 0,
|
||
borderBottom: `1px solid rgba(255,255,255,0.12)`,
|
||
}}>
|
||
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.92)", fontFamily: JM.fontSans }}>
|
||
✦ PRD saved — keep refining here or open the full document.
|
||
</div>
|
||
<Link
|
||
href={`/${workspace}/project/${projectId}/tasks`}
|
||
style={{
|
||
padding: "6px 14px", borderRadius: 8,
|
||
background: "#fff", color: JM.ink,
|
||
fontSize: 12, fontWeight: 600,
|
||
textDecoration: "none", flexShrink: 0,
|
||
fontFamily: JM.fontSans,
|
||
}}
|
||
>
|
||
View PRD →
|
||
</Link>
|
||
</div>
|
||
)}
|
||
|
||
{allDone && !dismissed && !hasPrd && (
|
||
<div style={{
|
||
background: JM.primaryGradient,
|
||
boxShadow: JM.primaryShadow,
|
||
padding: "14px 20px",
|
||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||
gap: 16, flexShrink: 0, flexWrap: "wrap",
|
||
borderBottom: `1px solid rgba(255,255,255,0.12)`,
|
||
}}>
|
||
<div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: "#fff", fontFamily: JM.fontDisplay, marginBottom: 2 }}>
|
||
✦ Discovery complete — what's next?
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.75)", fontFamily: JM.fontSans }}>
|
||
All 6 phases captured. Generate your PRD or open the MVP plan flow.
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, flexShrink: 0, alignItems: "center" }}>
|
||
<button
|
||
type="button"
|
||
onClick={handleGeneratePRD}
|
||
disabled={prdLoading}
|
||
style={{
|
||
padding: "8px 16px", borderRadius: 8, border: "none",
|
||
background: "#fff", color: JM.ink,
|
||
fontSize: 13, fontWeight: 700,
|
||
fontFamily: JM.fontSans, cursor: "pointer",
|
||
}}
|
||
>
|
||
{prdLoading ? "Navigating…" : "Generate PRD →"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleMVP}
|
||
style={{
|
||
padding: "8px 16px", borderRadius: 8,
|
||
border: "1px solid rgba(255,255,255,0.35)",
|
||
background: "transparent", color: "#fff",
|
||
fontSize: 13, fontWeight: 600,
|
||
fontFamily: JM.fontSans, cursor: "pointer",
|
||
}}
|
||
>
|
||
Plan MVP →
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDismissed(true)}
|
||
style={{
|
||
background: "none", border: "none", cursor: "pointer",
|
||
color: "rgba(255,255,255,0.55)", fontSize: 18, padding: "4px 6px",
|
||
}}
|
||
title="Dismiss"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<AtlasChat
|
||
projectId={projectId}
|
||
projectName={projectName}
|
||
chatContextRefs={chatContextRefs}
|
||
onRemoveChatContextRef={removeChatContextRef}
|
||
/>
|
||
</div>
|
||
|
||
{/* ── Right: Teams-style task rail (requirements = PRD sections as tasks) ── */}
|
||
<div style={{
|
||
width: 348, flexShrink: 0,
|
||
background: "#F4F2FA",
|
||
borderLeft: `1px solid ${JM.border}`,
|
||
display: "flex", flexDirection: "column",
|
||
overflow: "hidden",
|
||
}}>
|
||
{/* Tab bar */}
|
||
<div style={{
|
||
display: "flex", alignItems: "center",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
flexShrink: 0,
|
||
padding: "0 8px",
|
||
gap: 2,
|
||
background: "#FAF8FF",
|
||
}}>
|
||
<span style={{ display: "flex", padding: "10px 6px", color: JM.muted }} title="Panel">
|
||
<LayoutPanelLeft size={16} strokeWidth={1.75} />
|
||
</span>
|
||
{([
|
||
{ id: "tasks" as const, label: "Tasks" },
|
||
{ id: "phases" as const, label: "Phases" },
|
||
]).map(t => {
|
||
const active = sidebarTab === t.id;
|
||
return (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => setSidebarTab(t.id)}
|
||
style={{
|
||
padding: "10px 12px 8px",
|
||
border: "none",
|
||
background: "none",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontWeight: active ? 600 : 500,
|
||
color: active ? JM.ink : JM.muted,
|
||
fontFamily: JM.fontSans,
|
||
borderBottom: active ? `2px solid ${JM.indigo}` : "2px solid transparent",
|
||
marginBottom: -1,
|
||
}}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Search + tools */}
|
||
<div style={{
|
||
display: "flex", alignItems: "center", gap: 8,
|
||
padding: "8px 10px",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
background: "#FAF8FF",
|
||
}}>
|
||
<Search size={15} strokeWidth={1.75} color={JM.muted} style={{ flexShrink: 0 }} />
|
||
<input
|
||
type="search"
|
||
value={sectionSearch}
|
||
onChange={e => setSectionSearch(e.target.value)}
|
||
placeholder="Search sections…"
|
||
aria-label="Search sections"
|
||
style={{
|
||
flex: 1, minWidth: 0,
|
||
border: "none", background: "transparent",
|
||
fontSize: 12, fontFamily: JM.fontSans,
|
||
color: JM.ink, outline: "none",
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
title={sortAlpha ? "Sort: document order" : "Sort: A–Z"}
|
||
onClick={() => setSortAlpha(s => !s)}
|
||
style={{
|
||
border: "none", background: sortAlpha ? JV.violetTint : "transparent",
|
||
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
|
||
}}
|
||
>
|
||
<ArrowUpDown size={15} strokeWidth={1.75} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title={pendingOnly ? "Show all sections" : "Pending only"}
|
||
onClick={() => setPendingOnly(p => !p)}
|
||
style={{
|
||
border: "none", background: pendingOnly ? JV.violetTint : "transparent",
|
||
borderRadius: 6, padding: 6, cursor: "pointer", color: JM.mid,
|
||
}}
|
||
>
|
||
<Filter size={15} strokeWidth={1.75} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Scope + group (Tasks tab only shows group pills; Phases tab locks grouping) */}
|
||
<div style={{
|
||
padding: "8px 10px 10px",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
background: "#F7F5FC",
|
||
}}>
|
||
<select
|
||
value={phaseScope}
|
||
onChange={e => setPhaseScope(e.target.value)}
|
||
aria-label="Filter by discovery phase"
|
||
style={{
|
||
width: "100%",
|
||
padding: "8px 10px",
|
||
borderRadius: 8,
|
||
border: `1px solid ${JM.border}`,
|
||
background: "#fff",
|
||
fontSize: 12,
|
||
fontFamily: JM.fontSans,
|
||
color: JM.ink,
|
||
marginBottom: 8,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<option value="all">All sections</option>
|
||
{DISCOVERY_PHASES.map(p => (
|
||
<option key={p} value={p}>{PHASE_DISPLAY[p]}</option>
|
||
))}
|
||
</select>
|
||
{sidebarTab === "tasks" && (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||
<span style={{ fontSize: 10, fontWeight: 600, color: JM.muted, fontFamily: JM.fontSans }}>
|
||
Group by
|
||
</span>
|
||
{([
|
||
{ id: "none" as const, label: "None" },
|
||
{ id: "phase" as const, label: "Phase" },
|
||
{ id: "status" as const, label: "Status" },
|
||
]).map(opt => {
|
||
const on = groupBy === opt.id;
|
||
return (
|
||
<button
|
||
key={opt.id}
|
||
type="button"
|
||
onClick={() => setGroupBy(opt.id)}
|
||
style={{
|
||
padding: "4px 10px",
|
||
borderRadius: 999,
|
||
border: `1px solid ${on ? JM.indigo : JM.border}`,
|
||
background: on ? JV.violetTint : "#fff",
|
||
fontSize: 11,
|
||
fontWeight: on ? 600 : 500,
|
||
color: on ? JM.indigo : JM.mid,
|
||
fontFamily: JM.fontSans,
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{sidebarTab === "phases" && (
|
||
<div style={{ fontSize: 11, color: JM.muted, fontFamily: JM.fontSans }}>
|
||
Grouped by discovery phase
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Progress summary */}
|
||
<div style={{
|
||
padding: "10px 12px",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
background: "#F4F2FA",
|
||
flexShrink: 0,
|
||
}}>
|
||
<div style={{ height: 3, background: "#E0E7FF", borderRadius: 99, overflow: "hidden" }}>
|
||
<div style={{
|
||
height: "100%", borderRadius: 99,
|
||
background: JM.primaryGradient,
|
||
width: `${Math.round((completedSections / totalSections) * 100)}%`,
|
||
transition: "width 0.4s ease",
|
||
}} />
|
||
</div>
|
||
<div style={{ fontSize: 10, color: JM.muted, marginTop: 6, fontFamily: JM.fontSans }}>
|
||
{completedSections} of {totalSections} sections · Requirements task
|
||
</div>
|
||
<div style={{ fontSize: 10, color: JM.indigo, marginTop: 5, fontFamily: JM.fontSans, opacity: 0.9 }}>
|
||
Click a section row or phase header to attach it to your next message.
|
||
</div>
|
||
</div>
|
||
|
||
{/* Task list */}
|
||
<div style={{ flex: 1, overflowY: "auto", background: "#F4F2FA" }}>
|
||
{(() => {
|
||
const rows = filteredSections;
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div style={{
|
||
padding: "28px 16px",
|
||
textAlign: "center",
|
||
fontSize: 12,
|
||
color: JM.muted,
|
||
fontFamily: JM.fontSans,
|
||
lineHeight: 1.5,
|
||
}}>
|
||
No sections match your search or filters.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const renderRow = (label: string, phase: string | null, key: string) => {
|
||
const isDone = sectionDone(phase, savedPhaseIds, allDone);
|
||
const phaseSlug = phase ? phase.replace(/_/g, "-") : "prd";
|
||
const phaseLine = phase ? PHASE_DISPLAY[phase] ?? phase : "PRD";
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
title="Add this section to chat context for Vibn"
|
||
onClick={() => addSectionToChat(label, phase)}
|
||
style={{
|
||
padding: "10px 12px",
|
||
borderBottom: `1px solid rgba(229,231,235,0.85)`,
|
||
borderTop: "none",
|
||
borderLeft: "none",
|
||
borderRight: "none",
|
||
display: "flex", gap: 10, alignItems: "flex-start",
|
||
background: isDone ? "rgba(237,233,254,0.55)" : "transparent",
|
||
width: "100%",
|
||
textAlign: "left",
|
||
cursor: "pointer",
|
||
font: "inherit",
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 22, height: 22, borderRadius: "50%", flexShrink: 0, marginTop: 1,
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontSize: 11, fontWeight: 700,
|
||
background: isDone ? JM.indigo : "#fff",
|
||
border: isDone ? "none" : `1.5px solid ${JM.border}`,
|
||
color: isDone ? "#fff" : "transparent",
|
||
fontFamily: JM.fontSans,
|
||
}}>
|
||
{isDone ? "✓" : ""}
|
||
</div>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<div style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
color: JM.ink,
|
||
lineHeight: 1.3,
|
||
fontFamily: JM.fontSans,
|
||
}}>
|
||
{label}
|
||
</div>
|
||
<div style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 8,
|
||
marginTop: 4,
|
||
}}>
|
||
<span style={{
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
color: JM.indigo,
|
||
fontFamily: JM.fontSans,
|
||
}}>
|
||
{phaseSlug}
|
||
</span>
|
||
<span style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
color: isDone ? "#059669" : JM.muted,
|
||
fontFamily: JM.fontSans,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
}}>
|
||
{isDone ? "Done" : "Pending"}
|
||
</span>
|
||
</div>
|
||
<div style={{
|
||
fontSize: 10,
|
||
color: JM.muted,
|
||
marginTop: 3,
|
||
fontFamily: JM.fontSans,
|
||
lineHeight: 1.35,
|
||
}}>
|
||
Discovery · {phaseLine}
|
||
{!isDone ? " · complete in chat" : ""}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
};
|
||
|
||
if (effectiveGroupBy === "none") {
|
||
return rows.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`));
|
||
}
|
||
|
||
if (effectiveGroupBy === "phase") {
|
||
const byPhase = new Map<string, typeof rows>();
|
||
for (const r of rows) {
|
||
const pk = r.phase ?? "null";
|
||
if (!byPhase.has(pk)) byPhase.set(pk, []);
|
||
byPhase.get(pk)!.push(r);
|
||
}
|
||
const order = [...DISCOVERY_PHASES, "null"];
|
||
return order.flatMap(pk => {
|
||
const list = byPhase.get(pk);
|
||
if (!list?.length) return [];
|
||
const header = pk === "null" ? "Final" : PHASE_DISPLAY[pk] ?? pk;
|
||
const phaseClickable = pk !== "null";
|
||
return [
|
||
phaseClickable ? (
|
||
<button
|
||
key={`h-${pk}`}
|
||
type="button"
|
||
title={`Add discovery phase "${header}" to chat context`}
|
||
onClick={() => addPhaseToChat(pk, header)}
|
||
style={{
|
||
display: "block",
|
||
width: "100%",
|
||
padding: "8px 12px 6px",
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
letterSpacing: "0.06em",
|
||
textTransform: "uppercase",
|
||
color: JM.muted,
|
||
fontFamily: JM.fontSans,
|
||
background: "#EDE9FE",
|
||
border: "none",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
cursor: "pointer",
|
||
textAlign: "left",
|
||
}}
|
||
>
|
||
{header}
|
||
</button>
|
||
) : (
|
||
<div
|
||
key={`h-${pk}`}
|
||
style={{
|
||
padding: "8px 12px 4px",
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
letterSpacing: "0.06em",
|
||
textTransform: "uppercase",
|
||
color: JM.muted,
|
||
fontFamily: JM.fontSans,
|
||
background: "#EDE9FE",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
}}
|
||
>
|
||
{header}
|
||
</div>
|
||
),
|
||
...list.map(r => renderRow(r.label, r.phase, `${r.label}-${r.index}`)),
|
||
];
|
||
});
|
||
}
|
||
|
||
const doneRows = rows.filter(r => sectionDone(r.phase, savedPhaseIds, allDone));
|
||
const todoRows = rows.filter(r => !sectionDone(r.phase, savedPhaseIds, allDone));
|
||
const statusBlocks: ReactNode[] = [];
|
||
if (todoRows.length > 0) {
|
||
statusBlocks.push(
|
||
<div
|
||
key="h-todo"
|
||
style={{
|
||
padding: "8px 12px 4px",
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
letterSpacing: "0.06em",
|
||
textTransform: "uppercase",
|
||
color: JM.muted,
|
||
fontFamily: JM.fontSans,
|
||
background: "#EDE9FE",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
}}
|
||
>
|
||
To do
|
||
</div>
|
||
);
|
||
todoRows.forEach(r => {
|
||
statusBlocks.push(renderRow(r.label, r.phase, `todo-${r.label}-${r.index}`));
|
||
});
|
||
}
|
||
if (doneRows.length > 0) {
|
||
statusBlocks.push(
|
||
<div
|
||
key="h-done"
|
||
style={{
|
||
padding: "8px 12px 4px",
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
letterSpacing: "0.06em",
|
||
textTransform: "uppercase",
|
||
color: JM.muted,
|
||
fontFamily: JM.fontSans,
|
||
background: "#EDE9FE",
|
||
borderBottom: `1px solid ${JM.border}`,
|
||
}}
|
||
>
|
||
Done
|
||
</div>
|
||
);
|
||
doneRows.forEach(r => {
|
||
statusBlocks.push(renderRow(r.label, r.phase, `done-${r.label}-${r.index}`));
|
||
});
|
||
}
|
||
return statusBlocks;
|
||
})()}
|
||
</div>
|
||
|
||
{allDone && (
|
||
<div style={{ padding: "10px 12px", borderTop: `1px solid ${JM.border}`, flexShrink: 0, background: "#FAF8FF" }}>
|
||
<Link
|
||
href={`/${workspace}/project/${projectId}/tasks`}
|
||
style={{
|
||
display: "block", textAlign: "center",
|
||
padding: "10px 0", borderRadius: 8,
|
||
background: JM.primaryGradient,
|
||
color: "#fff",
|
||
fontSize: 12, fontWeight: 600,
|
||
textDecoration: "none",
|
||
fontFamily: JM.fontSans,
|
||
boxShadow: JM.primaryShadow,
|
||
}}
|
||
>
|
||
Open Tasks →
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|