import { useEffect, useMemo, useState } from "react"; import { Alert, Box, Center, Group, Loader, ScrollArea, Table, Tabs, Text, TextInput, UnstyledButton, } from "@mantine/core"; import { IconChevronDown, IconChevronUp, IconSearch, IconSelector, } from "@tabler/icons-react"; import { fetchGraph, fetchNodes, type NodeRow } from "../api"; import { formatFrontmatterValue } from "../format"; import { tipoStyle } from "../tipos"; import { useNodeCard } from "../NodeCardContext"; // Una pestaña por tipo de nodo real (no fantasma). Cada pestaña carga // perezosamente sus filas de /api/nodes?tipo= y las muestra en una tabla // Mantine ordenable + filtrable. Las columnas se deducen de las claves de // frontmatter más comunes del tipo. // Tipos que tienen tabla propia (los nodos reales del vault). Orden de aparición. const TABLE_TIPOS = [ "persona", "organizacion", "lugar", "documento", "caso", "dominio", ]; export function TablesView() { const [availableTipos, setAvailableTipos] = useState([]); const [active, setActive] = useState("persona"); const [error, setError] = useState(null); // Determinar qué tipos existen realmente en el vault (de los counts del grafo). useEffect(() => { let alive = true; fetchGraph() .then((d) => { if (!alive) return; const present = TABLE_TIPOS.filter((t) => (d.counts[t] ?? 0) > 0); // Añadir cualquier otro tipo real con nodos no contemplado arriba. const extra = Object.keys(d.counts).filter( (t) => (d.counts[t] ?? 0) > 0 && !TABLE_TIPOS.includes(t) && t !== "nota", ); const all = [...present, ...extra]; setAvailableTipos(all); if (all.length > 0 && !all.includes(active)) setActive(all[0]); }) .catch((e) => alive && setError(String(e))); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (error) { return (
{error}
); } if (availableTipos.length === 0) { return (
); } return ( v && setActive(v)} keepMounted={false} h="100%" style={{ display: "flex", flexDirection: "column" }} > {availableTipos.map((t) => ( } > {tipoStyle(t).label} ))} {availableTipos.map((t) => ( {active === t && } ))} ); } type SortDir = "asc" | "desc" | null; function TypeTable({ tipo }: { tipo: string }) { const { open } = useNodeCard(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState(""); const [sortCol, setSortCol] = useState("label"); const [sortDir, setSortDir] = useState("asc"); useEffect(() => { let alive = true; setLoading(true); fetchNodes(tipo) .then((d) => alive && setRows(d.rows)) .catch((e) => alive && setError(String(e))) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [tipo]); // Columnas: las claves de frontmatter más frecuentes de este conjunto, con // `label` (nombre del nodo) siempre primero. Limitado para no desbordar. const columns = useMemo(() => { const freq: Record = {}; for (const r of rows) { for (const k of Object.keys(r.frontmatter)) { if (k === "nombre" || k === "tipo") continue; // redundantes con label/tab freq[k] = (freq[k] ?? 0) + 1; } } const ranked = Object.entries(freq) .sort((a, b) => b[1] - a[1]) .slice(0, 6) .map(([k]) => k); return ["label", ...ranked]; }, [rows]); const cellValue = (row: NodeRow, col: string): string => { if (col === "label") return row.label; return formatFrontmatterValue(row.frontmatter[col]); }; const filtered = useMemo(() => { const q = filter.trim().toLowerCase(); let out = rows; if (q) { out = rows.filter((r) => columns.some((c) => cellValue(r, c).toLowerCase().includes(q)), ); } if (sortDir) { out = [...out].sort((a, b) => { const va = cellValue(a, sortCol).toLowerCase(); const vb = cellValue(b, sortCol).toLowerCase(); if (va < vb) return sortDir === "asc" ? -1 : 1; if (va > vb) return sortDir === "asc" ? 1 : -1; return 0; }); } return out; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows, filter, sortCol, sortDir, columns]); function toggleSort(col: string) { if (sortCol !== col) { setSortCol(col); setSortDir("asc"); } else { setSortDir((d) => (d === "asc" ? "desc" : d === "desc" ? null : "asc")); } } if (error) { return ( {error} ); } return ( } value={filter} onChange={(e) => setFilter(e.currentTarget.value)} w={300} /> {filtered.length} de {rows.length} {loading ? (
) : ( {columns.map((c) => ( toggleSort(c)}> {c === "label" ? "nombre" : c.replace(/_/g, " ")} ))} {filtered.map((r) => ( open(r.id)} > {columns.map((c) => ( {cellValue(r, c)} ))} ))} {filtered.length === 0 && ( Sin resultados )}
)}
); } function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) { if (!active || dir === null) return ; return dir === "asc" ? ( ) : ( ); }