mirror of
https://github.com/kharonsec/br-acc
synced 2026-04-25 17:15:02 +02:00
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:
@@ -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 />} />}
|
||||
|
||||
@@ -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;
|
||||
@@ -152,16 +154,16 @@ export function AppShell() {
|
||||
{NAV_ITEMS
|
||||
.filter((item) => !(IS_PUBLIC_MODE && item.path.includes("investigations")))
|
||||
.map(({ path, icon: Icon, labelKey }) => (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={`${styles.navItem} ${isActive(path) ? styles.navItemActive : ""}`}
|
||||
title={sidebarCollapsed ? t(labelKey) : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{!sidebarCollapsed && <span>{t(labelKey)}</span>}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={`${styles.navItem} ${isActive(path) ? styles.navItemActive : ""}`}
|
||||
title={sidebarCollapsed ? t(labelKey) : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{!sidebarCollapsed && <span>{t(labelKey)}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.sidebarFooter}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
155
frontend/src/pages/Emendas.module.css
Normal file
155
frontend/src/pages/Emendas.module.css
Normal 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;
|
||||
}
|
||||
170
frontend/src/pages/Emendas.tsx
Normal file
170
frontend/src/pages/Emendas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user