feat: implement FraudShield MVP — backend API + frontend alerts

Backend (Python 3.12/FastAPI):
- 5 models: Transaction, DetectionRule, FraudAlert, AuditLog, RuleSnapshot
- Rule engine: 4 rule types (amount, location, time, pattern)
- 11 API routes: health, auth, transactions, alerts
- Sync ingestion pipeline with explanation generation
- JWT auth with 3 roles (analyst, admin, senior)
- Seed script with 5 default rules + 50 synthetic transactions

Frontend (React 19/TypeScript/Tailwind):
- Auth: login page, JWT token management, role-based routing
- Alerts: queue with filters, detail panel, decision workflow
- Layout: responsive sidebar, top bar, protected routes
- Full TypeScript, zero ts-ignore, Tailwind CSS

Build: Backend imports clean, Frontend tsc --noEmit passes,
vite build produces 296KB production bundle.
This commit is contained in:
Felipe Domingues 2026-05-12 16:54:03 -03:00
parent 14f2616037
commit 989202648d
60 changed files with 6943 additions and 0 deletions

15
frontend/index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>FraudShield</title>
</head>
<body class="bg-gray-50 font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3258
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "fraudshield-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"@tanstack/react-query": "^5.0.0",
"recharts": "^2.15.0",
"lucide-react": "^0.460.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

38
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,38 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import Shell from './components/layout/Shell';
import { ProtectedRoute, AdminRoute } from './components/layout/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import AlertsPage from './pages/AlertsPage';
function Placeholder({ title }: { title: string }) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8 bg-white rounded-xl shadow-sm border">
<div className="text-4xl mb-3">🚧</div>
<h2 className="text-lg font-semibold text-gray-700">{title}</h2>
<p className="text-sm text-gray-500 mt-1">Em desenvolvimento</p>
</div>
</div>
);
}
export default function App() {
return (
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<Shell />}>
<Route path="/" element={<Navigate to="/alerts" replace />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route element={<AdminRoute />}>
<Route path="/rules" element={<Placeholder title="Gestão de Regras" />} />
</Route>
<Route path="/dashboard" element={<Placeholder title="Dashboard" />} />
</Route>
</Route>
</Routes>
</AuthProvider>
);
}

113
frontend/src/api/client.ts Normal file
View file

@ -0,0 +1,113 @@
const API_BASE = '/api/v1';
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('fraudshield_token');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: { ...getAuthHeaders(), ...((options.headers as Record<string, string>) || {}) },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
export interface AlertFilters {
status?: string;
min_score?: number;
from_date?: string;
to_date?: string;
triggered_rule?: string;
limit?: number;
offset?: number;
}
export interface TriggeredRule {
rule_id: string;
rule_name: string;
score: number;
explanation: string;
}
export interface AlertSummary {
id: string;
score: number;
status: string;
amount: number;
merchant_name: string;
top_rule: string;
created_at: string;
}
export interface AlertDetail {
id: string;
transaction_id: string;
score: number;
triggered_rules: TriggeredRule[];
explanation: string;
status: string;
assigned_to?: string | null;
decision?: string | null;
decided_by?: string | null;
decided_at?: string | null;
justification?: string | null;
created_at: string;
amount: number;
merchant_name: string;
merchant_category: string;
channel: string;
}
export interface AlertListResponse {
items: AlertSummary[];
total: number;
limit: number;
offset: number;
}
export interface AlertDecisionRequest {
decision: string;
justification?: string;
notes?: string;
}
export interface TransactionAccepted {
transaction_id: string;
status: string;
trace_id: string;
}
export interface DashboardMetrics {
total_transactions: number;
total_alerts: number;
fraud_rate_pct: number;
false_positive_rate_pct: number;
alerts_by_status: { pending: number; confirmed: number; false_positive: number; escalated: number };
}
export function listAlerts(params: AlertFilters = {}): Promise<AlertListResponse> {
const qs = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v !== undefined) qs.set(k, String(v)); });
return apiFetch<AlertListResponse>(`/alerts?${qs.toString()}`);
}
export function getAlert(id: string): Promise<AlertDetail> {
return apiFetch<AlertDetail>(`/alerts/${id}`);
}
export function decideAlert(id: string, data: AlertDecisionRequest): Promise<AlertDetail> {
return apiFetch<AlertDetail>(`/alerts/${id}/decide`, { method: 'POST', body: JSON.stringify(data) });
}
export function getDashboardMetrics(period = '24h'): Promise<DashboardMetrics> {
return apiFetch<DashboardMetrics>(`/dashboard/metrics?period=${period}`);
}
export { getAuthHeaders };

View file

@ -0,0 +1,38 @@
import type { AlertSummary } from '../../api/client';
import { formatCurrency, formatRelative, getScoreColor, getScoreBg, getStatusBadge, cn } from '../../lib/utils';
interface Props {
alert: AlertSummary;
isSelected: boolean;
onClick: () => void;
}
export default function AlertCard({ alert, isSelected, onClick }: Props) {
const badge = getStatusBadge(alert.status);
return (
<div
onClick={onClick}
className={cn(
'flex items-center gap-3 p-3 cursor-pointer transition-all duration-150 border-l-4',
isSelected ? 'bg-blue-50 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50',
)}
>
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white shrink-0', getScoreColor(alert.score))}>
{alert.score}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-sm text-gray-900">{formatCurrency(alert.amount)}</span>
<span className="text-xs text-gray-400 shrink-0">{formatRelative(alert.created_at)}</span>
</div>
<p className="text-sm text-gray-600 truncate">{alert.merchant_name}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs bg-gray-100 text-gray-600 rounded px-1.5 py-0.5">{alert.top_rule}</span>
<span className={cn('text-xs px-1.5 py-0.5 rounded font-medium', badge.cls)}>{badge.label}</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,200 @@
import { useState } from 'react';
import type { AlertDetail } from '../../api/client';
import { useAlertDetail, useDecideAlert } from '../../hooks/useAlerts';
import { formatCurrency, formatDate, getScoreColor, getScoreBg, getStatusBadge, cn } from '../../lib/utils';
import { ShieldOff, CheckCircle, ArrowUp, Loader2, FileText } from 'lucide-react';
interface Props {
alertId: string | null;
}
export default function AlertDetailPanel({ alertId }: Props) {
const { data: alert, isLoading } = useAlertDetail(alertId);
const { mutate: doDecide } = useDecideAlert();
const [decision, setDecision] = useState<string | null>(null);
const [justification, setJustification] = useState('');
const [submitting, setSubmitting] = useState(false);
if (!alertId) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Selecione um alerta para ver os detalhes</p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
</div>
);
}
if (!alert) {
return <div className="p-4 text-center text-red-500 text-sm">Alerta não encontrado</div>;
}
const badge = getStatusBadge(alert.status);
const isPending = alert.status === 'pending';
const handleDecide = async (d: string) => {
if (d === 'confirmed') {
setSubmitting(true);
try {
await doDecide(alert.id, { decision: d });
} finally {
setSubmitting(false);
}
return;
}
setDecision(d);
};
const submitWithJustification = async () => {
if (!decision) return;
setSubmitting(true);
try {
await doDecide(alert.id, { decision, justification });
} finally {
setSubmitting(false);
setDecision(null);
setJustification('');
}
};
return (
<div className="p-6 space-y-5 overflow-y-auto h-full">
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl">
<div className={cn('w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold text-white', getScoreColor(alert.score))}>
{alert.score}
</div>
<div>
<p className="text-sm font-medium text-gray-900">Score de Risco</p>
<p className="text-xs text-gray-500">de 100 pontos</p>
</div>
</div>
<div className="bg-white border rounded-xl p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-900 mb-2">Transação</h3>
<Row label="Valor" value={formatCurrency(alert.amount || 0)} />
<Row label="Estabelecimento" value={alert.merchant_name || '-'} />
<Row label="Categoria" value={alert.merchant_category || '-'} />
<Row label="Canal" value={alert.channel || '-'} />
</div>
{alert.triggered_rules && alert.triggered_rules.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900">Regras Acionadas</h3>
{alert.triggered_rules.map((rule, i) => (
<div key={i} className="bg-white border rounded-xl p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-900">{rule.rule_name}</span>
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', getScoreBg(rule.score))}>
+{rule.score}
</span>
</div>
<p className="text-xs text-gray-600">{rule.explanation}</p>
</div>
))}
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<p className="text-sm text-blue-900 whitespace-pre-line">{alert.explanation}</p>
</div>
{isPending && !decision && (
<div className="flex gap-2 pt-2">
<button
onClick={() => handleDecide('confirmed')}
disabled={submitting}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium rounded-lg transition-colors"
>
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ShieldOff className="w-4 h-4" />}
Confirmar Fraude
</button>
<button
onClick={() => handleDecide('false_positive')}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<CheckCircle className="w-4 h-4" />
Falso Positivo
</button>
<button
onClick={() => handleDecide('escalated')}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white text-sm font-medium rounded-lg transition-colors"
>
<ArrowUp className="w-4 h-4" />
Escalar
</button>
</div>
)}
{decision && decision !== 'confirmed' && (
<div className="border rounded-xl p-4 space-y-3">
<h4 className="text-sm font-medium text-gray-900">
Justificativa para "{decision === 'false_positive' ? 'Falso Positivo' : 'Escalar'}"
</h4>
<textarea
value={justification}
onChange={(e) => setJustification(e.target.value)}
className="w-full border rounded-lg p-2 text-sm"
rows={3}
placeholder="Descreva o motivo da decisão..."
/>
<div className="flex gap-2">
<button
onClick={submitWithJustification}
disabled={submitting || !justification.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white text-sm rounded-lg"
>
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Confirmar'}
</button>
<button onClick={() => setDecision(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
Cancelar
</button>
</div>
</div>
)}
{!isPending && alert.decision && (
<div className="border rounded-xl p-4 space-y-2 bg-gray-50">
<div className="flex items-center gap-2">
<span className={cn('text-sm px-3 py-1 rounded-full font-medium', getStatusBadge(alert.status).cls)}>
{getStatusBadge(alert.status).label}
</span>
</div>
{alert.decided_by && (
<p className="text-xs text-gray-500">
Decidido por <span className="font-medium">{alert.decided_by}</span>
{alert.decided_at && ` em ${formatDate(alert.decided_at)}`}
</p>
)}
{alert.justification && (
<p className="text-sm text-gray-600 italic mt-1">{alert.justification}</p>
)}
</div>
)}
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between text-sm">
<span className="text-gray-500">{label}</span>
<span className="text-gray-900 font-medium">{value}</span>
</div>
);
}

View file

@ -0,0 +1,86 @@
import { useState } from 'react';
import type { AlertFilters, AlertSummary } from '../../api/client';
import { useAlertQueue } from '../../hooks/useAlerts';
import AlertCard from './AlertCard';
import { Search, X } from 'lucide-react';
interface Props {
selectedId: string | null;
onSelect: (id: string) => void;
}
export default function AlertQueue({ selectedId, onSelect }: Props) {
const [filters, setFilters] = useState<AlertFilters>({ status: 'pending' });
const { data, isLoading, isError } = useAlertQueue(filters);
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b bg-white shrink-0 space-y-2">
<div className="flex gap-2">
<select
value={filters.status || ''}
onChange={(e) => setFilters(f => ({ ...f, status: e.target.value || undefined }))}
className="text-xs border rounded-lg px-2 py-1.5 bg-white"
>
<option value="">Todos</option>
<option value="pending">Pendentes</option>
<option value="confirmed">Confirmados</option>
<option value="false_positive">Falsos Positivos</option>
<option value="escalated">Escalados</option>
</select>
<input
type="number"
placeholder="Score mín."
className="text-xs border rounded-lg px-2 py-1.5 w-20"
onChange={(e) => setFilters(f => ({ ...f, min_score: e.target.value ? Number(e.target.value) : undefined }))}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-gray-100">
{isLoading && (
<div className="p-3 space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="w-10 h-10 rounded-full bg-gray-200" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-gray-200 rounded w-24" />
<div className="h-3 bg-gray-200 rounded w-32" />
</div>
</div>
))}
</div>
)}
{isError && (
<div className="p-4 text-center text-sm text-red-600">
Erro ao carregar alertas
</div>
)}
{data && data.items.length === 0 && (
<div className="p-8 text-center">
<Search className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">Nenhum alerta encontrado</p>
</div>
)}
{data?.items.map(alert => (
<AlertCard
key={alert.id}
alert={alert}
isSelected={alert.id === selectedId}
onClick={() => onSelect(alert.id)}
/>
))}
</div>
{data && (
<div className="p-2 border-t bg-white text-xs text-gray-500 text-center shrink-0">
{data.total} alertas
</div>
)}
</div>
);
}

View file

@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
export function ProtectedRoute() {
const { isAuthenticated, loading } = useAuth();
if (loading) return <div className="flex items-center justify-center h-screen"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /></div>;
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <Outlet />;
}
export function AdminRoute() {
const { isAdmin, loading } = useAuth();
if (loading) return null;
if (!isAdmin) return <Navigate to="/alerts" replace />;
return <Outlet />;
}

View file

@ -0,0 +1,89 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
import { Shield, AlertTriangle, ShieldCheck, BarChart3, Menu, X, LogOut } from 'lucide-react';
import { useState } from 'react';
export default function Shell() {
const { user, isAdmin, logout } = useAuth();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const pageTitles: Record<string, string> = {
'/alerts': 'Alertas',
'/rules': 'Regras',
'/dashboard': 'Dashboard',
};
const title = pageTitles[location.pathname] || 'FraudShield';
const links = [
{ to: '/alerts', icon: AlertTriangle, label: 'Alertas' },
...(isAdmin ? [{ to: '/rules', icon: ShieldCheck, label: 'Regras' }] : []),
{ to: '/dashboard', icon: BarChart3, label: 'Dashboard' },
];
return (
<div className="flex h-screen bg-gray-50">
<aside className={`
fixed inset-y-0 left-0 z-50 bg-[#1a1a2e] text-white transition-all duration-200
${sidebarOpen ? 'w-60' : 'w-16'}
md:relative md:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
`}>
<div className="flex items-center gap-2 p-4 border-b border-gray-700">
<Shield className="w-6 h-6 text-blue-400 shrink-0" />
{sidebarOpen && <span className="font-semibold text-sm">FraudShield</span>}
</div>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="md:hidden absolute top-4 right-[-40px] bg-[#1a1a2e] p-2 rounded-r-lg text-white"
>
{sidebarOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</button>
<nav className="mt-4 flex flex-col gap-1 px-2">
{links.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
isActive ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`
}
>
<Icon className="w-5 h-5 shrink-0" />
{sidebarOpen && <span>{label}</span>}
</NavLink>
))}
</nav>
</aside>
<div className="flex-1 flex flex-col min-w-0">
<header className="h-16 bg-white border-b flex items-center justify-between px-4 md:px-6 shrink-0">
<div className="flex items-center gap-3">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="md:hidden p-1">
<Menu className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:inline">{user?.name}</span>
<span className="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700 font-medium">
{user?.role}
</span>
<button onClick={logout} className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<LogOut className="w-4 h-4" />
</button>
</div>
</header>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,87 @@
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
interface User {
id: string;
email: string;
role: string;
name: string;
}
interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
isAuthenticated: boolean;
isAdmin: boolean;
isAnalyst: boolean;
isSenior: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthState>(null!);
function parseJWT(token: string): User | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return {
id: payload.sub || '',
email: payload.email || '',
role: payload.role || 'analyst',
name: (payload.email || '').split('@')[0],
};
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored = localStorage.getItem('fraudshield_token');
if (stored) {
const u = parseJWT(stored);
if (u) { setUser(u); setToken(stored); }
}
setLoading(false);
}, []);
const login = useCallback(async (email: string, password: string) => {
const form = new URLSearchParams();
form.set('username', email);
form.set('password', password);
const res = await fetch('/api/v1/auth/token', { method: 'POST', body: form });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
throw new Error(err.detail || 'Login failed');
}
const data = await res.json();
localStorage.setItem('fraudshield_token', data.access_token);
const u = parseJWT(data.access_token);
if (u) { setUser(u); setToken(data.access_token); }
}, []);
const logout = useCallback(() => {
localStorage.removeItem('fraudshield_token');
setUser(null);
setToken(null);
}, []);
const role = user?.role || 'analyst';
return (
<AuthContext.Provider value={{
user, token, loading,
isAuthenticated: !!token,
isAdmin: role === 'admin',
isAnalyst: role === 'analyst',
isSenior: role === 'senior',
login, logout,
}}>
{children}
</AuthContext.Provider>
);
}

View file

@ -0,0 +1,30 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { listAlerts, getAlert, decideAlert, type AlertFilters, type AlertDetail, type AlertDecisionRequest } from '../api/client';
export function useAlertQueue(filters: AlertFilters = {}) {
return useQuery({
queryKey: ['alerts', filters],
queryFn: () => listAlerts({ limit: 50, ...filters }),
refetchInterval: 30000,
});
}
export function useAlertDetail(id: string | null) {
return useQuery({
queryKey: ['alert', id],
queryFn: () => getAlert(id!),
enabled: !!id,
});
}
export function useDecideAlert() {
const qc = useQueryClient();
return {
mutate: async (id: string, data: AlertDecisionRequest) => {
const result = await decideAlert(id, data);
qc.invalidateQueries({ queryKey: ['alerts'] });
qc.invalidateQueries({ queryKey: ['alert', id] });
return result;
},
};
}

View file

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { AuthContext } from '../contexts/AuthContext';
export function useAuth() {
return useContext(AuthContext);
}

3
frontend/src/index.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

45
frontend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,45 @@
import { clsx } from 'clsx';
export const cn = (...args: Parameters<typeof clsx>) => clsx(args);
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'agora';
if (min < 60) return `${min} min`;
const h = Math.floor(min / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
export function getScoreColor(score: number): string {
if (score <= 30) return 'bg-green-500';
if (score <= 60) return 'bg-yellow-500';
if (score <= 80) return 'bg-orange-500';
return 'bg-red-500';
}
export function getScoreBg(score: number): string {
if (score <= 30) return 'bg-green-100 text-green-800';
if (score <= 60) return 'bg-yellow-100 text-yellow-800';
if (score <= 80) return 'bg-orange-100 text-orange-800';
return 'bg-red-100 text-red-800';
}
export function getStatusBadge(status: string): { label: string; cls: string } {
const map: Record<string, { label: string; cls: string }> = {
pending: { label: 'Pendente', cls: 'bg-blue-100 text-blue-800' },
confirmed: { label: 'Fraude', cls: 'bg-red-100 text-red-800' },
false_positive: { label: 'Falso Positivo', cls: 'bg-green-100 text-green-800' },
escalated: { label: 'Escalado', cls: 'bg-yellow-100 text-yellow-800' },
};
return map[status] || { label: status, cls: 'bg-gray-100 text-gray-800' };
}

18
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
);

View file

@ -0,0 +1,18 @@
import { useState } from 'react';
import AlertQueue from '../components/alerts/AlertQueue';
import AlertDetailPanel from '../components/alerts/AlertDetail';
export default function AlertsPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<div className="flex h-full">
<div className="w-2/5 border-r bg-white overflow-hidden">
<AlertQueue selectedId={selectedId} onSelect={setSelectedId} />
</div>
<div className="w-3/5 overflow-hidden">
<AlertDetailPanel alertId={selectedId} />
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { Shield, Loader2 } from 'lucide-react';
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/alerts');
} catch (err) {
setError(err instanceof Error ? err.message : 'Falha no login');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-blue-900 to-gray-800 p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-blue-600 mb-4">
<Shield className="w-7 h-7 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900">FraudShield</h1>
<p className="text-sm text-gray-500 mt-1">Motor de Detecção de Fraudes</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm"
placeholder="analista@fraudshield.local"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Senha</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm"
placeholder="••••••••"
required
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-lg text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Entrar
</button>
</form>
<div className="mt-6 p-3 bg-gray-50 rounded-lg text-xs text-gray-500">
<p className="font-medium mb-1">Credenciais de teste:</p>
<p>analista@fraudshield.local / qualquer senha</p>
<p>admin@fraudshield.local / qualquer senha</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
sidebar: '#1a1a2e',
accent: '#3b82f6',
},
},
},
plugins: [],
};

22
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src"]
}

11
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
});