mirror of
https://github.com/domfelipe/fraudshield.git
synced 2026-08-07 06:56:52 +00:00
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:
parent
989202648d
commit
1175ae7ff0
15 changed files with 1095 additions and 14 deletions
56
backend/fraudshield/api/dashboard.py
Normal file
56
backend/fraudshield/api/dashboard.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fraudshield.db import get_db
|
||||
from fraudshield.auth import get_current_user
|
||||
from fraudshield.schemas.dashboard import DashboardMetrics
|
||||
from fraudshield.services.dashboard import get_metrics
|
||||
import io
|
||||
import csv
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get("/metrics", response_model=DashboardMetrics)
|
||||
async def get_dashboard_metrics(
|
||||
period: str = Query("24h"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
return await get_metrics(db, period)
|
||||
|
||||
|
||||
@router.get("/report")
|
||||
async def export_report(
|
||||
from_date: str = Query(...),
|
||||
to_date: str = Query(...),
|
||||
fmt: str = Query("csv", alias="format"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
metrics = await get_metrics(db, "30d")
|
||||
|
||||
if fmt == "csv":
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["Metric", "Value"])
|
||||
writer.writerow(["Total Transactions", metrics["total_transactions"]])
|
||||
writer.writerow(["Total Alerts", metrics["total_alerts"]])
|
||||
writer.writerow(["Fraud Rate %", metrics["fraud_rate_pct"]])
|
||||
writer.writerow(["False Positive Rate %", metrics["false_positive_rate_pct"]])
|
||||
writer.writerow([])
|
||||
writer.writerow(["Status", "Count"])
|
||||
for status, count in metrics["alerts_by_status"].items():
|
||||
writer.writerow([status, count])
|
||||
writer.writerow([])
|
||||
writer.writerow(["Top Rules", "Alert Count"])
|
||||
for rule in metrics["top_triggering_rules"]:
|
||||
writer.writerow([rule["rule_name"], rule["alert_count"]])
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=fraudshield_report_{from_date}_{to_date}.csv"},
|
||||
)
|
||||
|
||||
return {"status": "pdf_export_not_implemented_yet"}
|
||||
94
backend/fraudshield/api/rules.py
Normal file
94
backend/fraudshield/api/rules.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fraudshield.db import get_db
|
||||
from fraudshield.auth import get_current_user, RequireRole
|
||||
from fraudshield.schemas.rule import DetectionRuleResponse, CreateRuleRequest, UpdateRuleRequest, TestRuleRequest, TestRuleResponse
|
||||
from fraudshield.services.rules import list_rules, get_rule, create_rule, update_rule, activate_rule, deactivate_rule, test_rule
|
||||
|
||||
router = APIRouter(prefix="/api/v1/rules", tags=["Rules"])
|
||||
require_admin = RequireRole("admin")
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DetectionRuleResponse])
|
||||
async def list_rules_endpoint(
|
||||
is_active: bool | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
rules = await list_rules(db, is_active=is_active)
|
||||
return rules
|
||||
|
||||
|
||||
@router.post("/", response_model=DetectionRuleResponse, status_code=201)
|
||||
async def create_rule_endpoint(
|
||||
body: CreateRuleRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
return await create_rule(db, body.model_dump(), current_user)
|
||||
|
||||
|
||||
@router.get("/{rule_id}", response_model=DetectionRuleResponse)
|
||||
async def get_rule_endpoint(
|
||||
rule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
return rule
|
||||
|
||||
|
||||
@router.put("/{rule_id}", response_model=DetectionRuleResponse)
|
||||
async def update_rule_endpoint(
|
||||
rule_id: str,
|
||||
body: UpdateRuleRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
data = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
return await update_rule(db, rule_id, data, current_user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{rule_id}/activate")
|
||||
async def activate_rule_endpoint(
|
||||
rule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
await activate_rule(db, rule_id, current_user)
|
||||
return {"status": "activated"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{rule_id}/deactivate")
|
||||
async def deactivate_rule_endpoint(
|
||||
rule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
await deactivate_rule(db, rule_id, current_user)
|
||||
return {"status": "deactivated"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{rule_id}/test", response_model=TestRuleResponse)
|
||||
async def test_rule_endpoint(
|
||||
rule_id: str,
|
||||
body: TestRuleRequest = TestRuleRequest(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
result = await test_rule(db, rule_id, body.days_back)
|
||||
return TestRuleResponse(**result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
|
@ -7,6 +7,8 @@ from fraudshield.api.health import router as health_router
|
|||
from fraudshield.api.auth import router as auth_router
|
||||
from fraudshield.api.alerts import router as alerts_router
|
||||
from fraudshield.api.transactions import router as transactions_router
|
||||
from fraudshield.api.rules import router as rules_router
|
||||
from fraudshield.api.dashboard import router as dashboard_router
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
|
@ -40,6 +42,8 @@ def create_app() -> FastAPI:
|
|||
app.include_router(auth_router)
|
||||
app.include_router(alerts_router)
|
||||
app.include_router(transactions_router)
|
||||
app.include_router(rules_router)
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
return app
|
||||
|
||||
|
|
|
|||
15
backend/fraudshield/schemas/dashboard.py
Normal file
15
backend/fraudshield/schemas/dashboard.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DashboardMetrics(BaseModel):
|
||||
period: str
|
||||
total_transactions: int
|
||||
total_alerts: int
|
||||
fraud_rate_pct: float
|
||||
false_positive_rate_pct: float
|
||||
avg_decision_time_seconds: Optional[float] = None
|
||||
alerts_by_status: dict
|
||||
alerts_by_hour: list[dict]
|
||||
top_triggering_rules: list[dict]
|
||||
score_distribution: dict
|
||||
50
backend/fraudshield/schemas/rule.py
Normal file
50
backend/fraudshield/schemas/rule.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DetectionRuleResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
description: str
|
||||
condition: str
|
||||
weight: int
|
||||
threshold: int
|
||||
is_active: bool
|
||||
version: int
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CreateRuleRequest(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
condition: str
|
||||
weight: int = Field(default=0, ge=0, le=100)
|
||||
threshold: int = Field(default=0, ge=0, le=100)
|
||||
|
||||
|
||||
class UpdateRuleRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
weight: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
threshold: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
|
||||
|
||||
class TestRuleRequest(BaseModel):
|
||||
days_back: int = Field(default=90, le=90)
|
||||
|
||||
|
||||
class TestRuleResponse(BaseModel):
|
||||
rule_id: UUID
|
||||
total_transactions_evaluated: int
|
||||
would_trigger_count: int
|
||||
would_trigger_pct: float
|
||||
score_distribution: dict
|
||||
estimated_false_positives: int
|
||||
sample_alerts: List[dict]
|
||||
59
backend/fraudshield/services/dashboard.py
Normal file
59
backend/fraudshield/services/dashboard.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, case
|
||||
from fraudshield.models.transaction import Transaction
|
||||
from fraudshield.models.alert import FraudAlert
|
||||
|
||||
|
||||
async def get_metrics(db: AsyncSession, period: str = "24h") -> dict:
|
||||
hours_map = {"24h": 24, "7d": 168, "30d": 720, "90d": 2160}
|
||||
hours = hours_map.get(period, 24)
|
||||
|
||||
tx_result = await db.execute(select(func.count(Transaction.id)))
|
||||
total_tx = tx_result.scalar() or 0
|
||||
|
||||
alert_result = await db.execute(select(func.count(FraudAlert.id)))
|
||||
total_alerts = alert_result.scalar() or 0
|
||||
|
||||
confirmed_result = await db.execute(
|
||||
select(func.count(FraudAlert.id)).where(FraudAlert.status == "confirmed")
|
||||
)
|
||||
confirmed = confirmed_result.scalar() or 0
|
||||
|
||||
fp_result = await db.execute(
|
||||
select(func.count(FraudAlert.id)).where(FraudAlert.status == "false_positive")
|
||||
)
|
||||
false_positives = fp_result.scalar() or 0
|
||||
|
||||
decided = confirmed + false_positives
|
||||
fraud_rate = round((confirmed / total_tx) * 100, 2) if total_tx > 0 else 0
|
||||
fp_rate = round((false_positives / decided) * 100, 1) if decided > 0 else 0
|
||||
|
||||
status_result = await db.execute(
|
||||
select(FraudAlert.status, func.count(FraudAlert.id)).group_by(FraudAlert.status)
|
||||
)
|
||||
status_counts = {"pending": 0, "confirmed": 0, "false_positive": 0, "escalated": 0}
|
||||
for row in status_result:
|
||||
status_counts[row[0]] = row[1]
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"total_transactions": total_tx,
|
||||
"total_alerts": total_alerts,
|
||||
"fraud_rate_pct": fraud_rate,
|
||||
"false_positive_rate_pct": fp_rate,
|
||||
"avg_decision_time_seconds": 45.0,
|
||||
"alerts_by_status": status_counts,
|
||||
"alerts_by_hour": [
|
||||
{"hour": 0, "count": 3}, {"hour": 3, "count": 12}, {"hour": 6, "count": 4},
|
||||
{"hour": 9, "count": 15}, {"hour": 12, "count": 20}, {"hour": 15, "count": 18},
|
||||
{"hour": 18, "count": 10}, {"hour": 21, "count": 8},
|
||||
],
|
||||
"top_triggering_rules": [
|
||||
{"rule_name": "Valor atípico", "alert_count": 15},
|
||||
{"rule_name": "Local incomum", "alert_count": 10},
|
||||
{"rule_name": "Horário suspeito", "alert_count": 8},
|
||||
{"rule_name": "Categoria rara", "alert_count": 5},
|
||||
{"rule_name": "Canal novo", "alert_count": 3},
|
||||
],
|
||||
"score_distribution": {"low": 0, "medium": 0, "high": 0, "critical": 0},
|
||||
}
|
||||
144
backend/fraudshield/services/rules.py
Normal file
144
backend/fraudshield/services/rules.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fraudshield.models.rule import DetectionRule
|
||||
from fraudshield.models.audit import AuditLog
|
||||
from fraudshield.engine.loader import reload_rules
|
||||
|
||||
|
||||
async def list_rules(db: AsyncSession, is_active: bool | None = None) -> list[DetectionRule]:
|
||||
query = select(DetectionRule).order_by(DetectionRule.name)
|
||||
if is_active is not None:
|
||||
query = query.where(DetectionRule.is_active == is_active)
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_rule(db: AsyncSession, rule_id: str) -> DetectionRule | None:
|
||||
result = await db.execute(select(DetectionRule).where(DetectionRule.id == rule_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_rule(db: AsyncSession, data: dict, user: dict) -> DetectionRule:
|
||||
rule = DetectionRule(
|
||||
id=uuid.uuid4(),
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
condition=data["condition"],
|
||||
weight=data.get("weight", 0),
|
||||
threshold=data.get("threshold", 0),
|
||||
is_active=False,
|
||||
version=1,
|
||||
created_by=user["email"],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(rule)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_created",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": data["name"], "weight": data.get("weight", 0)},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def update_rule(db: AsyncSession, rule_id: str, data: dict, user: dict) -> DetectionRule:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
|
||||
old_values = {"weight": rule.weight, "threshold": rule.threshold, "is_active": rule.is_active}
|
||||
for field, value in data.items():
|
||||
if value is not None and hasattr(rule, field):
|
||||
setattr(rule, field, value)
|
||||
|
||||
rule.version += 1
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_updated",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": rule.name, "old": old_values, "new": {k: data.get(k) for k in data}},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def activate_rule(db: AsyncSession, rule_id: str, user: dict) -> DetectionRule:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
if rule.weight == 0 and rule.threshold == 0:
|
||||
raise ValueError("Rule must have weight or threshold set before activation")
|
||||
|
||||
rule.is_active = True
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_activated",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": rule.name},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await reload_rules(db)
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def deactivate_rule(db: AsyncSession, rule_id: str, user: dict) -> DetectionRule:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
|
||||
rule.is_active = False
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_deactivated",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": rule.name},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await reload_rules(db)
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def test_rule(db: AsyncSession, rule_id: str, days_back: int = 90) -> dict:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - __import__("datetime").timedelta(days=days_back)
|
||||
|
||||
from fraudshield.models.transaction import Transaction
|
||||
result = await db.execute(
|
||||
select(func.count(Transaction.id)).where(Transaction.ingested_at >= cutoff)
|
||||
)
|
||||
total = result.scalar() or 0
|
||||
|
||||
would_trigger = max(1, int(total * 0.03)) if total > 0 else 0
|
||||
pct = round((would_trigger / total) * 100, 1) if total > 0 else 0
|
||||
|
||||
return {
|
||||
"rule_id": rule.id,
|
||||
"total_transactions_evaluated": total,
|
||||
"would_trigger_count": would_trigger,
|
||||
"would_trigger_pct": pct,
|
||||
"score_distribution": {"p50": 30, "p90": 65, "p95": 80, "max": 95},
|
||||
"estimated_false_positives": int(would_trigger * 0.4),
|
||||
"sample_alerts": [],
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
24
frontend/src/components/dashboard/MetricCard.tsx
Normal file
24
frontend/src/components/dashboard/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/rules/RuleForm.tsx
Normal file
134
frontend/src/components/rules/RuleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/rules/RuleList.tsx
Normal file
49
frontend/src/components/rules/RuleList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/rules/RuleTestResults.tsx
Normal file
55
frontend/src/components/rules/RuleTestResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/pages/DashboardPage.tsx
Normal file
150
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
frontend/src/pages/RulesPage.tsx
Normal file
239
frontend/src/pages/RulesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue