feat(frontend): add Emendas page with pagination, i18n, and responsive table (#63)

Maintainer triage on March 8, 2026: merged after manual label fix, branch update, and green required checks.
This commit is contained in:
lspassos1
2026-03-08 04:16:41 +00:00
committed by GitHub
parent 36cb016993
commit 4ab2a1aa30
5 changed files with 373 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ import { SharedInvestigation } from "./pages/SharedInvestigation";
import { useAuthStore } from "./stores/auth";
const EntityAnalysis = lazy(() => import("./pages/EntityAnalysis").then((m) => ({ default: m.EntityAnalysis })));
const Emendas = lazy(() => import("./pages/Emendas").then((m) => ({ default: m.Emendas })));
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@@ -76,6 +77,7 @@ export function App() {
<Route index element={<Dashboard />} />
<Route path="search" element={<Search />} />
<Route path="analysis/:entityId" element={<Suspense fallback={<Spinner />}><EntityAnalysis /></Suspense>} />
<Route path="emendas" element={<Suspense fallback={<Spinner />}><Emendas /></Suspense>} />
<Route path="graph/:entityId" element={<GraphRedirect />} />
{IS_PATTERNS_ENABLED && <Route path="patterns" element={<Patterns />} />}
{IS_PATTERNS_ENABLED && <Route path="patterns/:entityId" element={<Patterns />} />}

View File

@@ -11,6 +11,7 @@ import {
Moon,
Search,
Sun,
Landmark,
} from "lucide-react";
import { registerActions, type Action } from "@/actions/registry";
@@ -27,6 +28,7 @@ import styles from "./AppShell.module.css";
const NAV_ITEMS = [
{ path: "/app", icon: BarChart3, labelKey: "nav.dashboard" },
{ path: "/app/emendas", icon: Landmark, labelKey: "nav.emendas" },
{ path: "/app/search", icon: Search, labelKey: "nav.search" },
{ path: "/app/investigations", icon: FolderOpen, labelKey: "nav.investigations" },
] as const;

View File

@@ -114,8 +114,25 @@ const resources = {
noRecent: "Nenhuma investigação recente.",
quickStats: "Estatísticas",
},
emendas: {
title: "Emendas Parlamentares",
subtitle: "Visão de alto nível dos pagamentos extraídos via Governo Transparente (Neo4j)",
loading: "Carregando emendas...",
noLink: "Sem Vínculo Específico",
exploreNode: "Explorar Nó",
colOB: "Ordem Bancária (OB)",
colDate: "Data",
colType: "Tipo",
colValue: "Valor",
colBeneficiary: "Empresa Beneficiária (CNPJ)",
colActions: "Ações",
previous: "Anterior",
next: "Próxima",
pageInfo: "Página {{page}} de {{total}}",
},
nav: {
dashboard: "Painel",
emendas: "Emendas",
search: "Buscar",
graph: "Grafo",
patterns: "Padrões",
@@ -557,8 +574,25 @@ const resources = {
noRecent: "No recent investigations.",
quickStats: "Statistics",
},
emendas: {
title: "Parliamentary Amendments",
subtitle: "High-level overview of payments extracted from the Transparency Portal (Neo4j)",
loading: "Loading amendments...",
noLink: "No Specific Beneficiary",
exploreNode: "Explore Node",
colOB: "Bank Order (OB)",
colDate: "Date",
colType: "Type",
colValue: "Value",
colBeneficiary: "Beneficiary Company (CNPJ)",
colActions: "Actions",
previous: "Previous",
next: "Next",
pageInfo: "Page {{page}} of {{total}}",
},
nav: {
dashboard: "Dashboard",
emendas: "Amendments",
search: "Search",
graph: "Graph",
patterns: "Patterns",

View File

@@ -0,0 +1,155 @@
.container {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
font-family: inherit;
}
.header {
margin-bottom: 2rem;
}
.header h1 {
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--color-text-primary);
}
.header p {
color: var(--color-text-secondary);
font-size: 1.1rem;
}
.loadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
gap: 1rem;
color: var(--color-text-secondary);
}
.errorState {
padding: 1.5rem;
background-color: var(--color-error-light);
color: var(--color-error);
border-radius: 8px;
font-weight: 500;
}
.tableContainer {
background: var(--color-surface);
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
overflow: hidden;
border: 1px solid var(--color-border);
}
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.table th,
.table td {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.table th {
background-color: var(--color-surface-hover);
font-weight: 600;
color: var(--color-text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background-color: var(--color-surface-hover);
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: var(--color-primary-light);
color: var(--color-primary-dark);
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.val {
font-family: monospace;
font-weight: 600;
}
.beneficiaryInfo {
display: flex;
flex-direction: column;
}
.cnpjLabel {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-top: 0.2rem;
}
.actionBtn {
display: inline-block;
padding: 0.5rem 1rem;
background-color: var(--color-primary);
color: white;
border-radius: 4px;
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
transition: background-color 0.2s;
}
.actionBtn:hover {
background-color: var(--color-primary-dark);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 1.5rem 0;
}
.pageBtn {
padding: 0.6rem 1.2rem;
background-color: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.pageBtn:hover:not(:disabled) {
background-color: var(--color-surface-hover);
border-color: var(--color-primary);
}
.pageBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageInfo {
font-size: 0.9rem;
color: var(--color-text-secondary);
font-weight: 500;
}

View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Spinner } from "../components/common/Spinner";
import styles from "./Emendas.module.css";
interface EmendaRecord {
payment: Record<string, string | number | boolean | null>;
beneficiary: Record<string, string | number | boolean | null> | null;
}
interface EmendasResponse {
data: EmendaRecord[];
total_count: number;
skip: number;
limit: number;
}
const PAGE_SIZE = 50;
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
export function Emendas() {
const { t } = useTranslation();
const [data, setData] = useState<EmendasResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const fetchEmendas = useCallback(async (currentPage: number) => {
setLoading(true);
setError(null);
try {
const skip = currentPage * PAGE_SIZE;
const res = await fetch(
`${API_URL}/api/v1/emendas/?skip=${skip}&limit=${PAGE_SIZE}`,
);
if (!res.ok) {
throw new Error(`Error: ${res.statusText}`);
}
const json: EmendasResponse = await res.json();
setData(json);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchEmendas(page);
}, [page, fetchEmendas]);
const totalPages = data ? Math.max(1, Math.ceil(data.total_count / PAGE_SIZE)) : 1;
const formatCurrency = (val: unknown) => {
const num = typeof val === "number" ? val : 0;
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(num);
};
return (
<div className={styles.container}>
<header className={styles.header}>
<h1>{t("emendas.title")}</h1>
<p>{t("emendas.subtitle")}</p>
</header>
{loading && (
<div className={styles.loadingState}>
<Spinner />
<p>{t("emendas.loading")}</p>
</div>
)}
{error && <div className={styles.errorState}>{error}</div>}
{!loading && !error && data && (
<>
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
<tr>
<th>{t("emendas.colOB")}</th>
<th>{t("emendas.colDate")}</th>
<th>{t("emendas.colType")}</th>
<th>{t("emendas.colValue")}</th>
<th>{t("emendas.colBeneficiary")}</th>
<th>{t("emendas.colActions")}</th>
</tr>
</thead>
<tbody>
{data.data.map((record) => (
<tr key={String(record.payment.transfer_id)}>
<td>{String(record.payment.ob ?? "")}</td>
<td>{String(record.payment.date ?? "")}</td>
<td>
<span className={styles.badge}>
{String(record.payment.amendment_type ?? "")}
</span>
</td>
<td className={styles.val}>
{formatCurrency(record.payment.value)}
</td>
<td>
{record.beneficiary ? (
<div className={styles.beneficiaryInfo}>
<strong>
{String(record.beneficiary.razao_social ?? "")}
</strong>
<span className={styles.cnpjLabel}>
{String(record.beneficiary.cnpj ?? "")}
</span>
</div>
) : (
<span className={styles.cnpjLabel}>
{t("emendas.noLink")}
</span>
)}
</td>
<td>
{record.beneficiary?.cnpj ? (
<Link
to={`/app/analysis/cnpj_${String(record.beneficiary.cnpj)}`}
className={styles.actionBtn}
>
{t("emendas.exploreNode")}
</Link>
) : (
"-"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<button
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
{t("emendas.previous")}
</button>
<span className={styles.pageInfo}>
{t("emendas.pageInfo", {
page: page + 1,
total: totalPages,
})}
</span>
<button
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
{t("emendas.next")}
</button>
</div>
</>
)}
</div>
);
}