201 lines
6.2 KiB
TypeScript
201 lines
6.2 KiB
TypeScript
import { useState } from "react";
|
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
import { AlertCircle } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/Skeleton";
|
|
import { toast } from "sonner";
|
|
import { trpc } from "@/lib/trpc";
|
|
import { InitiativeHeader } from "@/components/InitiativeHeader";
|
|
import { ContentTab } from "@/components/editor/ContentTab";
|
|
import { ExecutionTab } from "@/components/ExecutionTab";
|
|
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
|
|
|
export const Route = createFileRoute("/initiatives/$id")({
|
|
component: InitiativeDetailPage,
|
|
});
|
|
|
|
type Tab = "content" | "execution";
|
|
|
|
function InitiativeDetailPage() {
|
|
const { id } = Route.useParams();
|
|
const navigate = useNavigate();
|
|
const [activeTab, setActiveTab] = useState<Tab>("content");
|
|
|
|
// Live updates: keep subscriptions at page level so they work across both tabs
|
|
const utils = trpc.useUtils();
|
|
|
|
// Task updates subscription with robust error handling
|
|
useSubscriptionWithErrorHandling(
|
|
() => trpc.onTaskUpdate.useSubscription(undefined),
|
|
{
|
|
onData: () => {
|
|
void utils.listPhases.invalidate();
|
|
void utils.listTasks.invalidate();
|
|
void utils.listPlans.invalidate();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
|
id: "sub-error",
|
|
duration: Infinity,
|
|
});
|
|
console.error('Task updates subscription error:', error);
|
|
},
|
|
onStarted: () => toast.dismiss("sub-error"),
|
|
autoReconnect: true,
|
|
maxReconnectAttempts: 5,
|
|
}
|
|
);
|
|
|
|
// Agent updates subscription with robust error handling
|
|
useSubscriptionWithErrorHandling(
|
|
() => trpc.onAgentUpdate.useSubscription(undefined),
|
|
{
|
|
onData: () => {
|
|
void utils.listAgents.invalidate();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
|
id: "sub-error",
|
|
duration: Infinity,
|
|
});
|
|
console.error('Agent updates subscription error:', error);
|
|
},
|
|
onStarted: () => toast.dismiss("sub-error"),
|
|
autoReconnect: true,
|
|
maxReconnectAttempts: 5,
|
|
}
|
|
);
|
|
|
|
// Page updates subscription with robust error handling
|
|
useSubscriptionWithErrorHandling(
|
|
() => trpc.onPageUpdate.useSubscription(undefined),
|
|
{
|
|
onData: () => {
|
|
void utils.listPages.invalidate();
|
|
void utils.getPage.invalidate();
|
|
void utils.getRootPage.invalidate();
|
|
},
|
|
onError: (error) => {
|
|
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
|
id: "sub-error",
|
|
duration: Infinity,
|
|
});
|
|
console.error('Page updates subscription error:', error);
|
|
},
|
|
onStarted: () => toast.dismiss("sub-error"),
|
|
autoReconnect: true,
|
|
maxReconnectAttempts: 5,
|
|
}
|
|
);
|
|
|
|
// tRPC queries
|
|
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
|
const phasesQuery = trpc.listPhases.useQuery(
|
|
{ initiativeId: id },
|
|
{ enabled: !!initiativeQuery.data },
|
|
);
|
|
|
|
// Loading state
|
|
if (initiativeQuery.isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<Skeleton className="h-4 w-4" />
|
|
<Skeleton className="h-7 w-64" />
|
|
<Skeleton className="h-5 w-20" />
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
|
<div className="space-y-1">
|
|
<Skeleton className="h-12 w-full rounded border" />
|
|
<Skeleton className="h-12 w-full rounded border" />
|
|
</div>
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-24 w-full rounded" />
|
|
<Skeleton className="h-20 w-full rounded" />
|
|
</div>
|
|
</div>
|
|
</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;
|
|
|
|
const serializedInitiative = {
|
|
id: initiative.id,
|
|
name: initiative.name,
|
|
status: initiative.status,
|
|
};
|
|
|
|
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
|
|
|
|
const phases = phasesQuery.data ?? [];
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Header */}
|
|
<InitiativeHeader
|
|
initiative={serializedInitiative}
|
|
projects={projects}
|
|
onBack={() => navigate({ to: "/initiatives" })}
|
|
/>
|
|
|
|
{/* Tab bar */}
|
|
<div className="flex gap-1 border-b border-border">
|
|
<button
|
|
onClick={() => setActiveTab("content")}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === "content"
|
|
? "border-primary text-foreground"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Content
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("execution")}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === "execution"
|
|
? "border-primary text-foreground"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Execution
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
|
|
{activeTab === "execution" && (
|
|
<ExecutionTab
|
|
initiativeId={id}
|
|
phases={phases}
|
|
phasesLoading={phasesQuery.isLoading}
|
|
phasesLoaded={phasesQuery.isSuccess}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|