feat(18-04): wire Initiative Detail page with data fetching and all components
Replace placeholder with full initiative detail page: tRPC data fetching (getInitiative, listPhases, listPlans, listTasks), PhaseWithTasks helper component pattern for stable hooks, two-column layout with phases on left and progress/decisions on right, TaskDetailModal with selectedTask state, queue actions for phases and tasks, loading/error states.
This commit is contained in:
@@ -1,16 +1,432 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { useState, useCallback } from "react";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { InitiativeHeader } from "@/components/InitiativeHeader";
|
||||||
|
import { ProgressPanel } from "@/components/ProgressPanel";
|
||||||
|
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
||||||
|
import { DecisionList } from "@/components/DecisionList";
|
||||||
|
import { TaskDetailModal } from "@/components/TaskDetailModal";
|
||||||
|
import type { SerializedTask } from "@/components/TaskRow";
|
||||||
|
|
||||||
export const Route = createFileRoute('/initiatives/$id')({
|
export const Route = createFileRoute("/initiatives/$id")({
|
||||||
component: InitiativeDetailPage,
|
component: InitiativeDetailPage,
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Aggregated task counts reported upward from PhaseWithTasks */
|
||||||
|
interface TaskCounts {
|
||||||
|
complete: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat task entry with metadata needed for the modal */
|
||||||
|
interface FlatTaskEntry {
|
||||||
|
task: SerializedTask;
|
||||||
|
phaseName: string;
|
||||||
|
agentName: string | null;
|
||||||
|
blockedBy: Array<{ name: string; status: string }>;
|
||||||
|
dependents: Array<{ name: string; status: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PhaseWithTasks — solves the "hooks inside loops" problem
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PhaseWithTasksProps {
|
||||||
|
phase: {
|
||||||
|
id: string;
|
||||||
|
initiativeId: string;
|
||||||
|
number: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
onTaskClick: (taskId: string) => void;
|
||||||
|
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||||
|
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseWithTasks({
|
||||||
|
phase,
|
||||||
|
defaultExpanded,
|
||||||
|
onTaskClick,
|
||||||
|
onTaskCounts,
|
||||||
|
registerTasks,
|
||||||
|
}: PhaseWithTasksProps) {
|
||||||
|
// Fetch all plans for this phase
|
||||||
|
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
|
||||||
|
|
||||||
|
// Fetch phase dependencies
|
||||||
|
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||||
|
|
||||||
|
const plans = plansQuery.data ?? [];
|
||||||
|
const planIds = plans.map((p) => p.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhaseWithTasksInner
|
||||||
|
phase={phase}
|
||||||
|
planIds={planIds}
|
||||||
|
plansLoaded={plansQuery.isSuccess}
|
||||||
|
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
||||||
|
defaultExpanded={defaultExpanded}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
onTaskCounts={onTaskCounts}
|
||||||
|
registerTasks={registerTasks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner component that fetches tasks for each plan — needs stable hook count
|
||||||
|
// Since planIds array changes, we fetch tasks per plan inside yet another child
|
||||||
|
interface PhaseWithTasksInnerProps {
|
||||||
|
phase: PhaseWithTasksProps["phase"];
|
||||||
|
planIds: string[];
|
||||||
|
plansLoaded: boolean;
|
||||||
|
phaseDependencyIds: string[];
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
onTaskClick: (taskId: string) => void;
|
||||||
|
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||||
|
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseWithTasksInner({
|
||||||
|
phase,
|
||||||
|
planIds,
|
||||||
|
plansLoaded,
|
||||||
|
phaseDependencyIds: _phaseDependencyIds,
|
||||||
|
defaultExpanded,
|
||||||
|
onTaskClick,
|
||||||
|
onTaskCounts,
|
||||||
|
registerTasks,
|
||||||
|
}: PhaseWithTasksInnerProps) {
|
||||||
|
// We can't call useQuery in a loop, so we render PlanTasksFetcher per plan
|
||||||
|
// and aggregate the results
|
||||||
|
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlanTasks = useCallback(
|
||||||
|
(planId: string, tasks: SerializedTask[]) => {
|
||||||
|
setPlanTasks((prev) => {
|
||||||
|
// Skip if unchanged (same reference)
|
||||||
|
if (prev[planId] === tasks) return prev;
|
||||||
|
const next = { ...prev, [planId]: tasks };
|
||||||
|
|
||||||
|
// Aggregate all tasks across plans
|
||||||
|
const allTasks = Object.values(next).flat();
|
||||||
|
const complete = allTasks.filter(
|
||||||
|
(t) => t.status === "completed",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Report counts up
|
||||||
|
onTaskCounts(phase.id, { complete, total: allTasks.length });
|
||||||
|
|
||||||
|
// Register flat entries for the modal lookup
|
||||||
|
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
|
||||||
|
task,
|
||||||
|
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
||||||
|
agentName: null, // No agent info from task data alone
|
||||||
|
blockedBy: [], // Simplified: no dependency lookup per task in v1
|
||||||
|
dependents: [],
|
||||||
|
}));
|
||||||
|
registerTasks(phase.id, entries);
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[phase.id, phase.number, phase.name, onTaskCounts, registerTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build task entries for PhaseAccordion
|
||||||
|
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
|
||||||
|
const taskEntries = allTasks.map((task) => ({
|
||||||
|
task,
|
||||||
|
agentName: null as string | null,
|
||||||
|
blockedBy: [] as Array<{ name: string; status: string }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Phase-level dependencies (empty for now — would need to resolve IDs to names)
|
||||||
|
const phaseDeps: Array<{ name: string; status: string }> = [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Hidden fetchers — one per plan */}
|
||||||
|
{plansLoaded &&
|
||||||
|
planIds.map((planId) => (
|
||||||
|
<PlanTasksFetcher
|
||||||
|
key={planId}
|
||||||
|
planId={planId}
|
||||||
|
onTasks={handlePlanTasks}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PhaseAccordion
|
||||||
|
phase={phase}
|
||||||
|
tasks={taskEntries}
|
||||||
|
defaultExpanded={defaultExpanded}
|
||||||
|
phaseDependencies={phaseDeps}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PlanTasksFetcher — fetches tasks for a single plan (stable hook count)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PlanTasksFetcherProps {
|
||||||
|
planId: string;
|
||||||
|
onTasks: (planId: string, tasks: SerializedTask[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
|
||||||
|
const tasksQuery = trpc.listTasks.useQuery({ planId });
|
||||||
|
|
||||||
|
// Report tasks upward when loaded
|
||||||
|
if (tasksQuery.isSuccess && tasksQuery.data) {
|
||||||
|
// Cast to SerializedTask — tRPC serializes Date to string over JSON
|
||||||
|
const tasks = tasksQuery.data as unknown as SerializedTask[];
|
||||||
|
onTasks(planId, tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Render nothing — this is a data-fetching component
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Page Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function InitiativeDetailPage() {
|
function InitiativeDetailPage() {
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
|
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
|
||||||
|
Record<string, TaskCounts>
|
||||||
|
>({});
|
||||||
|
const [tasksByPhase, setTasksByPhase] = useState<
|
||||||
|
Record<string, FlatTaskEntry[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// tRPC queries
|
||||||
|
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
||||||
|
const phasesQuery = trpc.listPhases.useQuery(
|
||||||
|
{ initiativeId: id },
|
||||||
|
{ enabled: !!initiativeQuery.data },
|
||||||
|
);
|
||||||
|
|
||||||
|
// tRPC mutations
|
||||||
|
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||||
|
const queuePhaseMutation = trpc.queuePhase.useMutation();
|
||||||
|
|
||||||
|
// Callbacks for PhaseWithTasks
|
||||||
|
const handleTaskCounts = useCallback(
|
||||||
|
(phaseId: string, counts: TaskCounts) => {
|
||||||
|
setTaskCountsByPhase((prev) => {
|
||||||
|
if (
|
||||||
|
prev[phaseId]?.complete === counts.complete &&
|
||||||
|
prev[phaseId]?.total === counts.total
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, [phaseId]: counts };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegisterTasks = useCallback(
|
||||||
|
(phaseId: string, entries: FlatTaskEntry[]) => {
|
||||||
|
setTasksByPhase((prev) => {
|
||||||
|
if (prev[phaseId] === entries) return prev;
|
||||||
|
return { ...prev, [phaseId]: entries };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived data
|
||||||
|
const phases = phasesQuery.data ?? [];
|
||||||
|
const phasesComplete = phases.filter(
|
||||||
|
(p) => p.status === "completed",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const allTaskCounts = Object.values(taskCountsByPhase);
|
||||||
|
const tasksComplete = allTaskCounts.reduce((s, c) => s + c.complete, 0);
|
||||||
|
const tasksTotal = allTaskCounts.reduce((s, c) => s + c.total, 0);
|
||||||
|
|
||||||
|
// Find selected task across all phases
|
||||||
|
const allFlatTasks = Object.values(tasksByPhase).flat();
|
||||||
|
const selectedEntry = selectedTaskId
|
||||||
|
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Determine which phase should be expanded by default (first non-completed)
|
||||||
|
const firstIncompletePhaseIndex = phases.findIndex(
|
||||||
|
(p) => p.status !== "completed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Queue all pending phases
|
||||||
|
const handleQueueAll = useCallback(() => {
|
||||||
|
const pendingPhases = phases.filter((p) => p.status === "pending");
|
||||||
|
for (const phase of pendingPhases) {
|
||||||
|
queuePhaseMutation.mutate({ phaseId: phase.id });
|
||||||
|
}
|
||||||
|
}, [phases, queuePhaseMutation]);
|
||||||
|
|
||||||
|
// Queue a single task
|
||||||
|
const handleQueueTask = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
queueTaskMutation.mutate({ taskId });
|
||||||
|
setSelectedTaskId(null);
|
||||||
|
},
|
||||||
|
[queueTaskMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (initiativeQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
|
Loading initiative...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (initiativeQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{initiativeQuery.error.message.includes("not found")
|
||||||
|
? "Initiative not found"
|
||||||
|
: `Failed to load initiative: ${initiativeQuery.error.message}`}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ to: "/initiatives" })}
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initiative = initiativeQuery.data;
|
||||||
|
if (!initiative) return null;
|
||||||
|
|
||||||
|
// tRPC serializes Date to string over JSON — cast to wire format
|
||||||
|
const serializedInitiative = {
|
||||||
|
id: initiative.id,
|
||||||
|
name: initiative.name,
|
||||||
|
status: initiative.status,
|
||||||
|
createdAt: String(initiative.createdAt),
|
||||||
|
updatedAt: String(initiative.updatedAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPendingPhases = phases.some((p) => p.status === "pending");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Initiative Detail</h1>
|
{/* Header */}
|
||||||
<p className="text-muted-foreground mt-2">Initiative ID: {id}</p>
|
<InitiativeHeader
|
||||||
<p className="text-muted-foreground mt-1">Content coming in Phase 18</p>
|
initiative={serializedInitiative}
|
||||||
|
onBack={() => navigate({ to: "/initiatives" })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||||
|
{/* Left column: Phases */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Section header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||||
|
<h2 className="text-lg font-semibold">Phases</h2>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPendingPhases}
|
||||||
|
onClick={handleQueueAll}
|
||||||
|
>
|
||||||
|
Queue All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase loading */}
|
||||||
|
{phasesQuery.isLoading && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
Loading phases...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phases list */}
|
||||||
|
{phasesQuery.isSuccess && phases.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
No phases yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phasesQuery.isSuccess &&
|
||||||
|
phases.map((phase, idx) => {
|
||||||
|
// tRPC serializes Date to string over JSON — cast to wire format
|
||||||
|
const serializedPhase = {
|
||||||
|
id: phase.id,
|
||||||
|
initiativeId: phase.initiativeId,
|
||||||
|
number: phase.number,
|
||||||
|
name: phase.name,
|
||||||
|
description: phase.description,
|
||||||
|
status: phase.status,
|
||||||
|
createdAt: String(phase.createdAt),
|
||||||
|
updatedAt: String(phase.updatedAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PhaseWithTasks
|
||||||
|
key={phase.id}
|
||||||
|
phase={serializedPhase}
|
||||||
|
defaultExpanded={idx === firstIncompletePhaseIndex}
|
||||||
|
onTaskClick={setSelectedTaskId}
|
||||||
|
onTaskCounts={handleTaskCounts}
|
||||||
|
registerTasks={handleRegisterTasks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: Progress + Decisions */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ProgressPanel
|
||||||
|
phasesComplete={phasesComplete}
|
||||||
|
phasesTotal={phases.length}
|
||||||
|
tasksComplete={tasksComplete}
|
||||||
|
tasksTotal={tasksTotal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DecisionList decisions={[]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Detail Modal */}
|
||||||
|
<TaskDetailModal
|
||||||
|
task={selectedEntry?.task ?? null}
|
||||||
|
phaseName={selectedEntry?.phaseName ?? ""}
|
||||||
|
agentName={selectedEntry?.agentName ?? null}
|
||||||
|
dependencies={selectedEntry?.blockedBy ?? []}
|
||||||
|
dependents={selectedEntry?.dependents ?? []}
|
||||||
|
onClose={() => setSelectedTaskId(null)}
|
||||||
|
onQueueTask={handleQueueTask}
|
||||||
|
onStopTask={() => setSelectedTaskId(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user