Submatter/apps/web/app/(app)/fleet/page.tsx

191 lines
8.6 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2, Radar } from "lucide-react";
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
function formatAge(date: Date | string): string {
const d = new Date(date);
const diff = Date.now() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}d`;
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours > 0) return `${hours}h`;
const mins = Math.floor(diff / (1000 * 60));
return `${mins}m`;
}
export default function FleetPage() {
const fleetQuery = api.convoys.fleet.useQuery();
const [selected, setSelected] = React.useState<{ convoyId: string; workspaceSlug: string } | null>(null);
if (fleetQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading fleet…
</div>
);
}
if (fleetQuery.error) {
return <p className="p-6 text-sm text-destructive">{fleetQuery.error.message}</p>;
}
const data = fleetQuery.data;
const byWorkspace = data?.byWorkspace ?? [];
const gateInboxAcross = data?.gateInboxAcross ?? [];
// Group gate inbox by status for "× pr_review 2d" count display
const gateByStatus = new Map<string, typeof gateInboxAcross>();
for (const c of gateInboxAcross) {
const arr = gateByStatus.get(c.status) ?? [];
arr.push(c);
gateByStatus.set(c.status, arr);
}
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Radar className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">Fleet</h1>
<p className="text-xs text-muted-foreground">All workspaces · gates needing you · live convoy events</p>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 lg:grid-cols-[260px_1fr_340px]">
{/* Needs you */}
<div className="flex min-h-0 flex-col gap-4">
<section className="rounded-lg border border-border bg-card">
<div className="border-b border-border p-4">
<h2 className="text-sm font-semibold">Needs you</h2>
<p className="text-xs text-muted-foreground">Gate inbox across all workspaces</p>
{gateInboxAcross.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
{[...gateByStatus.entries()].map(([status, list]) => (
<span key={status} className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-300">
× {status} {list.length} · {formatAge(list[0]?.updatedAt ?? new Date())}
</span>
))}
</div>
)}
</div>
<div className="p-3">
{gateInboxAcross.length === 0 ? (
<p className="text-sm text-muted-foreground">No pending gates.</p>
) : (
<ul className="space-y-2">
{gateInboxAcross.map((convoy) => (
<li key={`${convoy.workspaceId}-${convoy.id}`}>
<button
type="button"
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
className={cn(
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
selected?.convoyId === convoy.id && "ring-2 ring-amber-500",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
<p className="text-xs text-amber-700 dark:text-amber-300">{gateLabelForStatus(convoy.status as ConvoyStatus) ?? convoy.status}</p>
<p className="text-[10px] text-muted-foreground">
{convoy.workspaceName} · {convoy.workspaceSlug} · {formatAge(convoy.updatedAt)} ago
</p>
</button>
</li>
))}
</ul>
)}
</div>
</section>
<FleetActivityFeed />
</div>
{/* byWorkspace cards grid */}
<div className="min-h-0 space-y-4">
{byWorkspace.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">No workspaces or no convoys yet.</p>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{byWorkspace.map(({ workspace, convoys }) => {
const workspaceGateCount = gateInboxAcross.filter((c) => c.workspaceId === workspace.id).length;
return (
<div key={workspace.id} className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">{workspace.name}</h3>
<p className="text-xs text-muted-foreground">
{workspace.slug} · {convoys.length} convoys
{workspaceGateCount > 0 ? ` · ${workspaceGateCount} needing you` : ""}
</p>
</div>
<ul className="space-y-2 p-3">
{convoys.length === 0 ? (
<li className="text-xs text-muted-foreground">No convoys.</li>
) : (
convoys.slice(0, 8).map((convoy) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
className={cn(
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
selected?.convoyId === convoy.id && "ring-2 ring-primary",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
<p className="text-[10px] text-muted-foreground">
{convoy.status} · {convoy.content?.classification ?? ""} · {formatAge(convoy.updatedAt)} ago
</p>
</button>
</li>
))
)}
{convoys.length > 8 && (
<li className="text-center text-xs text-muted-foreground">+{convoys.length - 8} more</li>
)}
</ul>
</div>
);
})}
</div>
)}
{/* byStatus summary */}
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
<div className="rounded-lg border border-border bg-muted/10 p-3">
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">By status</p>
<div className="flex flex-wrap gap-2">
{Object.entries(data.byStatus).map(([status, list]) => (
<span key={status} className="rounded border border-border bg-background px-2 py-1 text-xs">
{status}: {(list as unknown[]).length}
</span>
))}
</div>
</div>
)}
</div>
{/* Detail panel workspace-agnostic */}
<aside className="min-h-0 border border-border bg-background lg:rounded-lg">
{selected ? (
<ConvoyDetailPanel workspaceSlug={selected.workspaceSlug} convoyId={selected.convoyId} />
) : (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
Select a convoy to review gates and co-pilot suggestions.
</div>
)}
</aside>
</div>
</div>
);
}