diff --git a/src/pages/Records.tsx b/src/pages/Records.tsx
new file mode 100644
index 0000000..7beea15
--- /dev/null
+++ b/src/pages/Records.tsx
@@ -0,0 +1,324 @@
+import { ReactElement, useEffect, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import {
+ CommonTable,
+ Column,
+ CellPrimitive,
+} from "../components/ui/CommonTable";
+import { DropdownFilter } from "../components/core/DropdownFilter";
+import { TextFilter } from "../components/core/TextFilter";
+import { getRecords } from "../clients/admin/sdk.gen";
+import type {
+ GetRecordsResponse,
+ Record as RecordType,
+ CrossmatchTriageStatus,
+} from "../clients/admin/types.gen";
+import { getResource } from "../resources/resources";
+import { Button } from "../components/core/Button";
+import { Loading } from "../components/core/Loading";
+import { ErrorPage } from "../components/ui/ErrorPage";
+import { Badge } from "../components/ui/Badge";
+import { Link } from "../components/core/Link";
+import { useDataFetching } from "../hooks/useDataFetching";
+import { Pagination } from "../components/ui/Pagination";
+import { adminClient } from "../clients/config";
+import type { ValidationError } from "../clients/admin/types.gen";
+
+interface RecordsFiltersProps {
+ tableName: string | null;
+ triageStatus: string | null;
+ pageSize: number;
+ onApplyFilters: (
+ tableName: string,
+ triageStatus: string,
+ pageSize: number,
+ ) => void;
+}
+
+function RecordsFilters({
+ tableName,
+ triageStatus,
+ pageSize,
+ onApplyFilters,
+}: RecordsFiltersProps): ReactElement {
+ const [localTriageStatus, setLocalTriageStatus] = useState
(
+ triageStatus ?? "all",
+ );
+ const [localPageSize, setLocalPageSize] = useState(pageSize);
+ const [localTableName, setLocalTableName] = useState(tableName || "");
+
+ useEffect(() => {
+ setLocalTriageStatus(triageStatus ?? "all");
+ setLocalPageSize(pageSize);
+ setLocalTableName(tableName || "");
+ }, [triageStatus, pageSize, tableName]);
+
+ function applyFilters(): void {
+ onApplyFilters(localTableName, localTriageStatus, localPageSize);
+ }
+
+ return (
+
+
+
+
+
setLocalPageSize(parseInt(value))}
+ />
+
+
+
+
+ );
+}
+
+interface RecordsTableProps {
+ data: GetRecordsResponse | null;
+ loading?: boolean;
+ showCandidates?: boolean;
+}
+
+function RecordsTable({
+ data,
+ loading,
+ showCandidates = false,
+}: RecordsTableProps): ReactElement {
+ function getRecordName(record: RecordType): ReactElement {
+ const displayName = record.catalogs?.designation?.name || record.id;
+ return {displayName};
+ }
+
+ function getTriageStatusLabel(status: CrossmatchTriageStatus): string {
+ return getResource(`crossmatch.triage.${status}`).Title;
+ }
+
+ function renderCandidates(record: RecordType): ReactElement {
+ const pgcNumbers = record.crossmatch.candidates.map((c) => c.pgc);
+ return (
+ <>
+ {pgcNumbers.map((pgc, index) => (
+
+ {pgc}
+
+ ))}
+ >
+ );
+ }
+
+ const nameHint = data?.schema?.catalogs?.designation?.description?.name;
+
+ const columns: Column[] = [
+ {
+ name: "Name",
+ hint: nameHint ? {nameHint}
: undefined,
+ renderCell: (recordIndex: CellPrimitive) => {
+ if (typeof recordIndex === "number" && data?.records[recordIndex]) {
+ return getRecordName(data.records[recordIndex]);
+ }
+ return —;
+ },
+ },
+ {
+ name: "Manual check status",
+ renderCell: (recordIndex: CellPrimitive) => {
+ if (typeof recordIndex === "number" && data?.records[recordIndex]) {
+ return getTriageStatusLabel(
+ data.records[recordIndex].crossmatch.triage_status,
+ );
+ }
+ return —;
+ },
+ },
+ {
+ name: "Nature",
+ renderCell: (recordIndex: CellPrimitive) => {
+ if (typeof recordIndex === "number" && data?.records[recordIndex]) {
+ const typeName =
+ data.records[recordIndex].catalogs?.nature?.type_name;
+ return {typeName ?? "—"};
+ }
+ return —;
+ },
+ },
+ ...(showCandidates
+ ? [
+ {
+ name: "Candidates",
+ renderCell: (recordIndex: CellPrimitive) => {
+ if (
+ typeof recordIndex === "number" &&
+ data?.records[recordIndex]
+ ) {
+ return renderCandidates(data.records[recordIndex]);
+ }
+ return —;
+ },
+ },
+ ]
+ : []),
+ ];
+
+ const tableData: Record[] =
+ data?.records.map((_record: RecordType, index: number) => {
+ const row: Record = {
+ Name: index,
+ "Manual check status": index,
+ Nature: index,
+ };
+ if (showCandidates) {
+ row["Candidates"] = index;
+ }
+ return row;
+ }) || [];
+
+ return ;
+}
+
+async function fetcher(
+ tableName: string | null,
+ triageStatus: CrossmatchTriageStatus | null,
+ page: number,
+ pageSize: number,
+): Promise {
+ if (!tableName) {
+ throw new Error("Table name is required");
+ }
+
+ const response = await getRecords({
+ client: adminClient,
+ query: {
+ table_name: tableName,
+ triage_status: triageStatus,
+ page,
+ page_size: pageSize,
+ },
+ });
+
+ if (response.error) {
+ throw new Error(
+ response.error.detail
+ ?.map((err: ValidationError) => err.msg)
+ .join(", ") || "Failed to fetch records",
+ );
+ }
+
+ if (!response.data) {
+ throw new Error("No data received from server");
+ }
+
+ return response.data.data;
+}
+
+export function RecordsPage(): ReactElement {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const tableName = searchParams.get("table_name");
+ const triageStatusParam = searchParams.get("triage_status");
+ const apiTriageStatus: CrossmatchTriageStatus | null =
+ triageStatusParam === null || triageStatusParam === ""
+ ? null
+ : triageStatusParam === "all"
+ ? null
+ : (triageStatusParam as CrossmatchTriageStatus);
+ const page = parseInt(searchParams.get("page") || "0");
+ const pageSize = parseInt(searchParams.get("page_size") || "25");
+
+ useEffect(() => {
+ document.title = `Records${tableName ? ` - ${tableName}` : ""} | HyperLEDA`;
+ }, [tableName]);
+
+ const { data, loading, error } = useDataFetching(
+ () => fetcher(tableName, apiTriageStatus, page, pageSize),
+ [tableName, apiTriageStatus, page, pageSize],
+ );
+
+ function handlePageChange(newPage: number): void {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("page", newPage.toString());
+ setSearchParams(newSearchParams);
+ }
+
+ function handleApplyFilters(
+ newTableName: string,
+ newTriageStatus: string,
+ newPageSize: number,
+ ): void {
+ const newSearchParams = new URLSearchParams(searchParams);
+
+ if (newTableName.trim()) {
+ newSearchParams.set("table_name", newTableName.trim());
+ } else {
+ newSearchParams.delete("table_name");
+ }
+
+ if (newTriageStatus === "all") {
+ newSearchParams.delete("triage_status");
+ } else {
+ newSearchParams.set("triage_status", newTriageStatus);
+ }
+
+ newSearchParams.set("page_size", newPageSize.toString());
+ newSearchParams.set("page", "0");
+
+ setSearchParams(newSearchParams);
+ }
+
+ function Content(): ReactElement {
+ if (error && !data) return ;
+ if (!data?.records && loading) return ;
+ if (!data?.records) return ;
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ Records
+
+
+ >
+ );
+}
diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx
index 5eb2c47..4204e39 100644
--- a/src/pages/TableDetails.tsx
+++ b/src/pages/TableDetails.tsx
@@ -198,11 +198,25 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement {
return (
-
+
Crossmatch Statistics
-
+
+
+
+
);