feat: implement rule management (US3) + dashboard (US4)

US3 - Rule Management:
- Backend: CRUD API + activate/deactivate + test against 90d history
- Frontend: RuleList sidebar, RuleForm (create/edit), RuleTestResults
- Weight/threshold sliders, condition type selector, audit logging

US4 - Dashboard:
- Backend: GET /dashboard/metrics (24h/7d/30d/90d), CSV export
- Frontend: MetricCards, alerts-by-hour bar chart, status pie chart,
  top rules horizontal bar chart via Recharts

Stack: 20 API routes total. Frontend tsc clean, vite build passes.
This commit is contained in:
Felipe Domingues 2026-05-12 19:14:44 -03:00
parent 989202648d
commit 1175ae7ff0
15 changed files with 1095 additions and 14 deletions

View file

@ -4,18 +4,8 @@ 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>
);
}
import RulesPage from './pages/RulesPage';
import DashboardPage from './pages/DashboardPage';
export default function App() {
return (
@ -27,9 +17,9 @@ export default function App() {
<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 path="/rules" element={<RulesPage />} />
</Route>
<Route path="/dashboard" element={<Placeholder title="Dashboard" />} />
<Route path="/dashboard" element={<DashboardPage />} />
</Route>
</Route>
</Routes>

View file

@ -85,11 +85,29 @@ export interface TransactionAccepted {
}
export interface DashboardMetrics {
period: string;
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 };
alerts_by_hour: { hour: number; count: number }[];
top_triggering_rules: { rule_name: string; alert_count: number }[];
score_distribution: { low: number; medium: number; high: number; critical: number };
}
export interface DetectionRule {
id: string;
name: string;
description: string;
condition: string;
weight: number;
threshold: number;
is_active: boolean;
version: number;
created_by: string;
created_at: string;
updated_at: string;
}
export function listAlerts(params: AlertFilters = {}): Promise<AlertListResponse> {

View file

@ -0,0 +1,24 @@
interface Props {
label: string;
value: string | number;
sub?: string;
color?: string;
}
export default function MetricCard({ label, value, sub, color = 'blue' }: Props) {
const colors: Record<string, string> = {
blue: 'border-blue-200 bg-blue-50',
red: 'border-red-200 bg-red-50',
green: 'border-green-200 bg-green-50',
yellow: 'border-yellow-200 bg-yellow-50',
purple: 'border-purple-200 bg-purple-50',
};
return (
<div className={`border rounded-xl p-4 ${colors[color] || colors.blue}`}>
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className="text-2xl font-bold text-gray-900">{value}</p>
{sub && <p className="text-xs text-gray-500 mt-1">{sub}</p>}
</div>
);
}

View file

@ -0,0 +1,134 @@
import { useState, useEffect } from 'react';
import type { DetectionRule } from '../../api/client';
import { Save, X } from 'lucide-react';
interface Props {
rule: DetectionRule | null;
onSave: (data: { name: string; description: string; condition: string; weight: number; threshold: number }) => Promise<void>;
onCancel: () => void;
saving: boolean;
}
export default function RuleForm({ rule, onSave, onCancel, saving }: Props) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [condition, setCondition] = useState('');
const [weight, setWeight] = useState(0);
const [threshold, setThreshold] = useState(0);
useEffect(() => {
if (rule) {
setName(rule.name);
setDescription(rule.description);
setCondition(rule.condition);
setWeight(rule.weight);
setThreshold(rule.threshold);
} else {
setName('');
setDescription('');
setCondition('');
setWeight(0);
setThreshold(0);
}
}, [rule]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ name, description, condition, weight, threshold });
};
const isNew = !rule;
return (
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{isNew ? 'Nova Regra' : 'Editar Regra'}
</h3>
<button type="button" onClick={onCancel} className="p-1 hover:bg-gray-100 rounded">
<X className="w-4 h-4" />
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nome</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Descrição</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
rows={2}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Regra
</label>
<select
value={condition}
onChange={e => setCondition(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
required
>
<option value="">Selecione...</option>
<option value="amount_rule">Valor atípico</option>
<option value="location_rule">Local incomum</option>
<option value="time_rule">Horário suspeito</option>
<option value="pattern_rule">Padrão incomum</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Peso (0-100)</label>
<input
type="range"
min={0}
max={100}
value={weight}
onChange={e => setWeight(Number(e.target.value))}
className="w-full"
/>
<span className="text-xs text-gray-500">{weight}</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Threshold (0-100)</label>
<input
type="range"
min={0}
max={100}
value={threshold}
onChange={e => setThreshold(Number(e.target.value))}
className="w-full"
/>
<span className="text-xs text-gray-500">{threshold}</span>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={saving}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white text-sm rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{saving ? 'Salvando...' : 'Salvar'}
</button>
<button type="button" onClick={onCancel} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
Cancelar
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,49 @@
import { useState } from 'react';
import type { DetectionRule } from '../../api/client';
import { getStatusBadge, cn } from '../../lib/utils';
interface Props {
rules: DetectionRule[];
selectedId: string | null;
onSelect: (id: string) => void;
onRefresh: () => void;
}
export default function RuleList({ rules, selectedId, onSelect, onRefresh }: Props) {
if (rules.length === 0) {
return (
<div className="p-8 text-center text-sm text-gray-500">
Nenhuma regra cadastrada
</div>
);
}
return (
<div className="divide-y">
{rules.map(rule => {
const activeBadge = rule.is_active
? { label: 'Ativa', cls: 'bg-green-100 text-green-800' }
: { label: 'Inativa', cls: 'bg-gray-100 text-gray-600' };
return (
<div
key={rule.id}
onClick={() => onSelect(rule.id)}
className={cn(
'flex items-center justify-between p-3 cursor-pointer transition-colors hover:bg-gray-50 border-l-4',
selectedId === rule.id ? 'bg-blue-50 border-blue-500' : 'border-transparent',
)}
>
<div>
<p className="text-sm font-medium text-gray-900">{rule.name}</p>
<p className="text-xs text-gray-500 mt-0.5">Peso: {rule.weight} | v{rule.version}</p>
</div>
<span className={cn('text-xs px-2 py-1 rounded-full font-medium', activeBadge.cls)}>
{activeBadge.label}
</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,55 @@
import { BarChart3 } from 'lucide-react';
interface Props {
result: {
total_transactions_evaluated: number;
would_trigger_count: number;
would_trigger_pct: number;
estimated_false_positives: number;
} | null;
loading: boolean;
}
export default function RuleTestResults({ result, loading }: Props) {
if (loading) {
return (
<div className="p-4 border rounded-xl bg-white">
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-48" />
<div className="h-8 bg-gray-200 rounded w-32" />
<div className="h-4 bg-gray-200 rounded w-64" />
</div>
</div>
);
}
if (!result) return null;
return (
<div className="p-4 border rounded-xl bg-white space-y-3">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-blue-600" />
<h4 className="text-sm font-semibold text-gray-900">Resultado do Teste</h4>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">Transações avaliadas</p>
<p className="text-lg font-semibold text-gray-900">{result.total_transactions_evaluated.toLocaleString()}</p>
</div>
<div className="bg-yellow-50 rounded-lg p-3">
<p className="text-xs text-gray-500">Disparariam</p>
<p className="text-lg font-semibold text-yellow-700">{result.would_trigger_count.toLocaleString()}</p>
</div>
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-xs text-gray-500">% de transações</p>
<p className="text-lg font-semibold text-blue-700">{result.would_trigger_pct}%</p>
</div>
<div className="bg-red-50 rounded-lg p-3">
<p className="text-xs text-gray-500">Est. falsos positivos</p>
<p className="text-lg font-semibold text-red-700">{result.estimated_false_positives}</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,150 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { useAuth } from '../hooks/useAuth';
import MetricCard from '../components/dashboard/MetricCard';
import { Download, BarChart3, RefreshCw } from 'lucide-react';
import { cn } from '../lib/utils';
interface DashboardMetrics {
period: string;
total_transactions: number;
total_alerts: number;
fraud_rate_pct: number;
false_positive_rate_pct: number;
alerts_by_status: Record<string, number>;
alerts_by_hour: { hour: number; count: number }[];
top_triggering_rules: { rule_name: string; alert_count: number }[];
score_distribution: Record<string, number>;
}
async function apiFetch(path: string) {
const token = localStorage.getItem('fraudshield_token');
const res = await fetch(`/api/v1${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
return res.json();
}
const STATUS_COLORS: Record<string, string> = {
pending: '#3b82f6',
confirmed: '#ef4444',
false_positive: '#22c55e',
escalated: '#eab308',
};
export default function DashboardPage() {
const { isAdmin } = useAuth();
const [period, setPeriod] = useState('24h');
const { data, isLoading, refetch } = useQuery<DashboardMetrics>({
queryKey: ['dashboard', period],
queryFn: () => apiFetch(`/dashboard/metrics?period=${period}`),
refetchInterval: 30000,
});
if (isLoading || !data) {
return (
<div className="flex items-center justify-center h-full">
<RefreshCw className="w-6 h-6 animate-spin text-blue-500" />
</div>
);
}
const statusPie = Object.entries(data.alerts_by_status)
.filter(([, v]) => v > 0)
.map(([name, value]) => ({ name, value }));
return (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Dashboard</h2>
<div className="flex items-center gap-2">
{['24h', '7d', '30d', '90d'].map(p => (
<button
key={p}
onClick={() => setPeriod(p)}
className={cn(
'px-3 py-1 text-xs rounded-lg font-medium transition-colors',
period === p ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200',
)}
>
{p}
</button>
))}
{isAdmin && (
<a
href={`/api/v1/dashboard/report?from_date=2026-01-01&to_date=2026-12-31&format=csv`}
className="flex items-center gap-1 px-3 py-1 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700"
>
<Download className="w-3 h-3" />
CSV
</a>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MetricCard label="Transações" value={data.total_transactions.toLocaleString()} color="blue" />
<MetricCard label="Alertas" value={data.total_alerts.toLocaleString()} color="red" />
<MetricCard label="Taxa de Fraude" value={`${data.fraud_rate_pct}%`} color="yellow" />
<MetricCard label="Falsos Positivos" value={`${data.false_positive_rate_pct}%`} color="green" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white border rounded-xl p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Alertas por Hora</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.alerts_by_hour}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" tickFormatter={h => `${h}h`} />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-white border rounded-xl p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Status dos Alertas</h3>
{statusPie.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={statusPie}
cx="50%"
cy="50%"
outerRadius={80}
dataKey="value"
label={({ name, value }) => `${name}: ${value}`}
>
{statusPie.map(entry => (
<Cell key={entry.name} fill={STATUS_COLORS[entry.name] || '#6b7280'} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[200px] text-sm text-gray-400">
Sem dados
</div>
)}
</div>
<div className="bg-white border rounded-xl p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-gray-900 mb-4">Top Regras por Alertas</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.top_triggering_rules} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="rule_name" type="category" width={130} tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="alert_count" fill="#8b5cf6" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,239 @@
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../hooks/useAuth';
import type { DetectionRule } from '../api/client';
import RuleList from '../components/rules/RuleList';
import RuleForm from '../components/rules/RuleForm';
import RuleTestResults from '../components/rules/RuleTestResults';
import { Plus, Play, Power, PowerOff, Loader2, ShieldCheck, RefreshCw } from 'lucide-react';
import { cn } from '../lib/utils';
interface TestResult {
total_transactions_evaluated: number;
would_trigger_count: number;
would_trigger_pct: number;
estimated_false_positives: number;
}
async function apiFetch(path: string, options: RequestInit = {}) {
const token = localStorage.getItem('fraudshield_token');
const res = await fetch(`/api/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as Record<string, string> || {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
export default function RulesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [mode, setMode] = useState<'view' | 'create' | 'edit'>('view');
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const { data: rules = [], isLoading, refetch } = useQuery<DetectionRule[]>({
queryKey: ['rules'],
queryFn: () => apiFetch('/rules'),
refetchInterval: 15000,
});
const selectedRule = rules.find(r => r.id === selectedId) || null;
const handleCreate = () => {
setSelectedId(null);
setMode('create');
setTestResult(null);
};
const handleEdit = () => {
if (selectedRule) setMode('edit');
};
const handleSave = useCallback(async (data: { name: string; description: string; condition: string; weight: number; threshold: number }) => {
setSaving(true);
try {
if (mode === 'create') {
await apiFetch('/rules', { method: 'POST', body: JSON.stringify(data) });
} else if (selectedRule) {
await apiFetch(`/rules/${selectedRule.id}`, { method: 'PUT', body: JSON.stringify(data) });
}
setMode('view');
setTestResult(null);
refetch();
qc.invalidateQueries({ queryKey: ['rules'] });
} catch (err) {
alert(err instanceof Error ? err.message : 'Erro ao salvar');
} finally {
setSaving(false);
}
}, [mode, selectedRule, refetch, qc]);
const handleToggle = async () => {
if (!selectedRule) return;
setActionLoading(selectedRule.id);
try {
const endpoint = selectedRule.is_active ? 'deactivate' : 'activate';
await apiFetch(`/rules/${selectedRule.id}/${endpoint}`, { method: 'POST' });
refetch();
qc.invalidateQueries({ queryKey: ['rules'] });
} catch (err) {
alert(err instanceof Error ? err.message : 'Erro ao alterar status');
} finally {
setActionLoading(null);
}
};
const handleTest = async () => {
if (!selectedRule) return;
setTesting(true);
try {
const result = await apiFetch(`/rules/${selectedRule.id}/test`, {
method: 'POST',
body: JSON.stringify({ days_back: 90 }),
});
setTestResult(result);
} catch (err) {
alert(err instanceof Error ? err.message : 'Erro ao testar');
} finally {
setTesting(false);
}
};
if (!isAdmin) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<ShieldCheck className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-700">Acesso Restrito</h2>
<p className="text-sm text-gray-500 mt-1">Apenas administradores podem gerenciar regras</p>
</div>
</div>
);
}
return (
<div className="flex h-full">
<div className="w-72 border-r bg-white flex flex-col shrink-0">
<div className="p-3 border-b flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">Regras ({rules.length})</h3>
<button onClick={handleCreate} className="p-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
<Plus className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-3 space-y-2 animate-pulse">
{[1, 2, 3].map(i => <div key={i} className="h-12 bg-gray-100 rounded" />)}
</div>
) : (
<RuleList rules={rules} selectedId={selectedId} onSelect={id => { setSelectedId(id); setMode('view'); setTestResult(null); }} onRefresh={refetch} />
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{mode === 'create' || mode === 'edit' ? (
<RuleForm
rule={mode === 'edit' ? selectedRule : null}
onSave={handleSave}
onCancel={() => { setMode('view'); setTestResult(null); }}
saving={saving}
/>
) : selectedRule ? (
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">{selectedRule.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">{selectedRule.description}</p>
</div>
<span className={cn(
'text-xs px-2.5 py-1 rounded-full font-medium',
selectedRule.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
)}>
{selectedRule.is_active ? 'Ativa' : 'Inativa'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 bg-gray-50 rounded-xl p-4">
<div>
<p className="text-xs text-gray-500">Peso no Score</p>
<p className="text-lg font-semibold">{selectedRule.weight}</p>
</div>
<div>
<p className="text-xs text-gray-500">Threshold</p>
<p className="text-lg font-semibold">{selectedRule.threshold}</p>
</div>
<div>
<p className="text-xs text-gray-500">Tipo</p>
<p className="text-sm font-medium">{selectedRule.condition}</p>
</div>
<div>
<p className="text-xs text-gray-500">Versão</p>
<p className="text-sm font-medium">v{selectedRule.version}</p>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<button onClick={handleEdit} className="flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg">
Editar
</button>
<button
onClick={handleToggle}
disabled={actionLoading === selectedRule.id}
className={cn(
'flex items-center gap-1.5 px-3 py-2 text-white text-sm rounded-lg transition-colors',
selectedRule.is_active
? 'bg-yellow-600 hover:bg-yellow-700'
: 'bg-green-600 hover:bg-green-700',
)}
>
{actionLoading === selectedRule.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : selectedRule.is_active ? (
<PowerOff className="w-4 h-4" />
) : (
<Power className="w-4 h-4" />
)}
{selectedRule.is_active ? 'Desativar' : 'Ativar'}
</button>
<button
onClick={handleTest}
disabled={testing}
className="flex items-center gap-1.5 px-3 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white text-sm rounded-lg"
>
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
Testar (90d)
</button>
</div>
<RuleTestResults result={testResult} loading={testing} />
<div className="text-xs text-gray-400 space-y-1">
<p>Criado por: {selectedRule.created_by}</p>
<p>Criado em: {new Date(selectedRule.created_at).toLocaleString('pt-BR')}</p>
<p>Atualizado em: {new Date(selectedRule.updated_at).toLocaleString('pt-BR')}</p>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
<div className="text-center">
<ShieldCheck className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Selecione uma regra ou crie uma nova</p>
</div>
</div>
)}
</div>
</div>
);
}