diff --git a/app/sandboxes/orgs/page.tsx b/app/sandboxes/orgs/page.tsx new file mode 100644 index 0000000..14cefa3 --- /dev/null +++ b/app/sandboxes/orgs/page.tsx @@ -0,0 +1,10 @@ +import OrgsPage from "@/components/Orgs/OrgsPage"; + +export const metadata = { + title: "Org Repos — Recoup Admin", + description: "View all GitHub org repositories and their commit statistics.", +}; + +export default function Page() { + return ; +} diff --git a/components/Orgs/OrgsPage.tsx b/components/Orgs/OrgsPage.tsx new file mode 100644 index 0000000..38789f6 --- /dev/null +++ b/components/Orgs/OrgsPage.tsx @@ -0,0 +1,17 @@ +import OrgsTableContainer from "@/components/Orgs/OrgsTableContainer"; + +export default function OrgsPage() { + return ( + + + + Org Repos + + + All GitHub org repositories and their commit statistics. + + + + + ); +} diff --git a/components/Orgs/OrgsTable.tsx b/components/Orgs/OrgsTable.tsx new file mode 100644 index 0000000..faf9a62 --- /dev/null +++ b/components/Orgs/OrgsTable.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from "@tanstack/react-table"; +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { orgsColumns } from "@/components/Orgs/orgsColumns"; +import type { OrgRepoRow } from "@/types/org"; + +interface OrgsTableProps { + repos: OrgRepoRow[]; +} + +/** + * Renders a shadcn Data Table of org repos and their commit statistics. + * Default sort: Total Commits descending (most commits first). + * + * @param repos - Array of org repo rows from the admin API + */ +export default function OrgsTable({ repos }: OrgsTableProps) { + const [sorting, setSorting] = useState([ + { id: "total_commits", desc: true }, + ]); + + const table = useReactTable({ + data: repos, + columns: orgsColumns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + + + + ); +} diff --git a/components/Orgs/OrgsTableContainer.tsx b/components/Orgs/OrgsTableContainer.tsx new file mode 100644 index 0000000..aceef9d --- /dev/null +++ b/components/Orgs/OrgsTableContainer.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useAdminSandboxOrgs } from "@/hooks/useAdminSandboxOrgs"; +import OrgsTable from "@/components/Orgs/OrgsTable"; + +export default function OrgsTableContainer() { + const { data: repos, isLoading, error } = useAdminSandboxOrgs(); + + if (isLoading) { + return ( + + Loading… + + ); + } + + if (error) { + return ( + + {error instanceof Error ? error.message : "Failed to load org repos"} + + ); + } + + if (!repos || repos.length === 0) { + return ( + + No org repo data found. + + ); + } + + return ; +} diff --git a/components/Orgs/orgsColumns.tsx b/components/Orgs/orgsColumns.tsx new file mode 100644 index 0000000..53c1098 --- /dev/null +++ b/components/Orgs/orgsColumns.tsx @@ -0,0 +1,119 @@ +import { type ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { OrgRepoRow } from "@/types/org"; + +export const orgsColumns: ColumnDef[] = [ + { + accessorKey: "repo_name", + header: "Repository", + cell: ({ row }) => ( + + {row.getValue("repo_name")} + + ), + }, + { + accessorKey: "total_commits", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + className="-ml-3 h-8 px-3" + > + Total Commits + {column.getIsSorted() === "asc" ? ( + + ) : column.getIsSorted() === "desc" ? ( + + ) : ( + + )} + + ), + }, + { + accessorKey: "latest_commit_messages", + header: "Latest Commits", + cell: ({ row }) => { + const messages = row.getValue("latest_commit_messages"); + return ( + + {messages.map((msg, i) => ( + + {msg} + + ))} + + ); + }, + }, + { + accessorKey: "earliest_committed_at", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + className="-ml-3 h-8 px-3" + > + First Commit + {column.getIsSorted() === "asc" ? ( + + ) : column.getIsSorted() === "desc" ? ( + + ) : ( + + )} + + ), + cell: ({ row }) => + new Date(row.getValue("earliest_committed_at")).toLocaleString(), + sortingFn: "datetime", + }, + { + accessorKey: "latest_committed_at", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + className="-ml-3 h-8 px-3" + > + Latest Commit + {column.getIsSorted() === "asc" ? ( + + ) : column.getIsSorted() === "desc" ? ( + + ) : ( + + )} + + ), + cell: ({ row }) => + new Date(row.getValue("latest_committed_at")).toLocaleString(), + sortingFn: "datetime", + }, + { + accessorKey: "account_repo_count", + header: ({ column }) => ( + column.toggleSorting(column.getIsSorted() === "asc")} + className="-ml-3 h-8 px-3" + > + Account Repos + {column.getIsSorted() === "asc" ? ( + + ) : column.getIsSorted() === "desc" ? ( + + ) : ( + + )} + + ), + }, +]; diff --git a/hooks/useAdminSandboxOrgs.ts b/hooks/useAdminSandboxOrgs.ts new file mode 100644 index 0000000..7d12555 --- /dev/null +++ b/hooks/useAdminSandboxOrgs.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { fetchAdminSandboxOrgs } from "@/lib/fetchAdminSandboxOrgs"; + +/** + * Fetches org repo commit statistics from GET /api/admins/sandboxes/orgs + * using TanStack Query. Authenticates with the Privy access token. + */ +export function useAdminSandboxOrgs() { + const { ready, authenticated, getAccessToken } = usePrivy(); + + return useQuery({ + queryKey: ["admin", "sandboxes", "orgs"], + queryFn: async () => { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + return fetchAdminSandboxOrgs(token); + }, + enabled: ready && authenticated, + }); +} diff --git a/lib/fetchAdminSandboxOrgs.ts b/lib/fetchAdminSandboxOrgs.ts new file mode 100644 index 0000000..790e300 --- /dev/null +++ b/lib/fetchAdminSandboxOrgs.ts @@ -0,0 +1,25 @@ +import { API_BASE_URL } from "@/lib/consts"; +import type { OrgRepoRow } from "@/types/org"; + +/** + * Fetches org repo commit statistics from GET /api/admins/sandboxes/orgs. + * Authenticates using the caller's Privy access token. + * + * @param accessToken - Privy access token from getAccessToken() + * @returns Array of org repo rows + */ +export async function fetchAdminSandboxOrgs( + accessToken: string, +): Promise { + const res = await fetch(`${API_BASE_URL}/api/admins/sandboxes/orgs`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? `HTTP ${res.status}`); + } + + const data = await res.json(); + return data.repos ?? []; +} diff --git a/types/org.ts b/types/org.ts new file mode 100644 index 0000000..aefec76 --- /dev/null +++ b/types/org.ts @@ -0,0 +1,14 @@ +export interface OrgRepoRow { + repo_name: string; + repo_url: string; + total_commits: number; + latest_commit_messages: string[]; + earliest_committed_at: string; + latest_committed_at: string; + account_repo_count: number; +} + +export interface AdminSandboxOrgsResponse { + status: "success" | "error"; + repos: OrgRepoRow[]; +}
+ All GitHub org repositories and their commit statistics. +