feat: Portal Web React + TailwindCSS
- Dashboard with stats cards and quick actions - Issues list with filters and status badges - Repositories management with add/reindex/delete - Modules editor for business rules - Settings page for integrations (JIRA, Bitbucket, LLM) - Responsive sidebar navigation - React Query for data fetching Pages: Dashboard, Issues, Repositories, Modules, Settings
This commit is contained in:
parent
dcf0988790
commit
011a93c5b9
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ACI AI Fixer - Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "aci-ai-fixer-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"lucide-react": "^0.303.0",
|
||||||
|
"clsx": "^2.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Issues from './pages/Issues'
|
||||||
|
import Repositories from './pages/Repositories'
|
||||||
|
import Modules from './pages/Modules'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/issues" element={<Issues />} />
|
||||||
|
<Route path="/repositories" element={<Repositories />} />
|
||||||
|
<Route path="/modules" element={<Modules />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
AlertCircle,
|
||||||
|
GitBranch,
|
||||||
|
Brain,
|
||||||
|
Settings,
|
||||||
|
Cpu
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||||
|
{ name: 'Issues', href: '/issues', icon: AlertCircle },
|
||||||
|
{ name: 'Repositórios', href: '/repositories', icon: GitBranch },
|
||||||
|
{ name: 'Módulos', href: '/modules', icon: Brain },
|
||||||
|
{ name: 'Configurações', href: '/settings', icon: Settings },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-64 bg-gray-900 text-white">
|
||||||
|
<div className="p-4 flex items-center gap-2">
|
||||||
|
<Cpu className="h-8 w-8 text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-lg">ACI AI Fixer</h1>
|
||||||
|
<p className="text-xs text-gray-400">v0.1.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mt-4">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-800 text-white border-r-2 border-blue-400'
|
||||||
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
|
{navigation.find(n => n.href === location.pathname)?.name || 'ACI AI Fixer'}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Ambiente: <span className="text-green-600 font-medium">Desenvolvimento</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 p-6 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM 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({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
XCircle,
|
||||||
|
TrendingUp,
|
||||||
|
Activity
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
icon: React.ReactNode
|
||||||
|
trend?: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsCard({ title, value, icon, trend, color }: StatsCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">{title}</p>
|
||||||
|
<p className="text-3xl font-bold mt-1">{value}</p>
|
||||||
|
{trend && (
|
||||||
|
<p className="text-sm text-green-600 mt-1 flex items-center gap-1">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
{trend}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`p-3 rounded-full ${color}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data: stats, isLoading } = useQuery({
|
||||||
|
queryKey: ['stats'],
|
||||||
|
queryFn: () => api.get('/api/issues/stats/summary').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Activity className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<StatsCard
|
||||||
|
title="Total de Issues"
|
||||||
|
value={stats?.total_issues || 0}
|
||||||
|
icon={<AlertTriangle className="h-6 w-6 text-white" />}
|
||||||
|
color="bg-blue-500"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Pendentes"
|
||||||
|
value={stats?.pending || 0}
|
||||||
|
icon={<Clock className="h-6 w-6 text-white" />}
|
||||||
|
color="bg-yellow-500"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Aceitas"
|
||||||
|
value={stats?.accepted || 0}
|
||||||
|
icon={<CheckCircle className="h-6 w-6 text-white" />}
|
||||||
|
color="bg-green-500"
|
||||||
|
trend={stats?.success_rate ? `${(stats.success_rate * 100).toFixed(0)}% sucesso` : undefined}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Rejeitadas"
|
||||||
|
value={stats?.rejected || 0}
|
||||||
|
icon={<XCircle className="h-6 w-6 text-white" />}
|
||||||
|
color="bg-red-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold">Atividade Recente</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
<Activity className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p>Nenhuma atividade recente</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Configure os webhooks do JIRA para começar a receber issues
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold">Ações Rápidas</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<button className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-left">
|
||||||
|
<h4 className="font-medium">Testar Conexão JIRA</h4>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Verificar integração com o servidor JIRA</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-left">
|
||||||
|
<h4 className="font-medium">Re-indexar Repositórios</h4>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Atualizar índice de código fonte</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-left">
|
||||||
|
<h4 className="font-medium">Testar LLM</h4>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Verificar conexão com modelo de IA</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
Activity
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
type IssueStatus = 'pending' | 'analyzing' | 'analyzed' | 'fix_generated' | 'pr_created' | 'accepted' | 'rejected' | 'failed'
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
id: string
|
||||||
|
jira_key: string
|
||||||
|
title: string
|
||||||
|
status: IssueStatus
|
||||||
|
module?: string
|
||||||
|
confidence?: number
|
||||||
|
created_at: string
|
||||||
|
pr_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<IssueStatus, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
|
pending: { label: 'Pendente', color: 'bg-gray-100 text-gray-800', icon: <Clock className="h-4 w-4" /> },
|
||||||
|
analyzing: { label: 'Analisando', color: 'bg-blue-100 text-blue-800', icon: <Activity className="h-4 w-4 animate-spin" /> },
|
||||||
|
analyzed: { label: 'Analisado', color: 'bg-purple-100 text-purple-800', icon: <CheckCircle className="h-4 w-4" /> },
|
||||||
|
fix_generated: { label: 'Fix Gerado', color: 'bg-indigo-100 text-indigo-800', icon: <CheckCircle className="h-4 w-4" /> },
|
||||||
|
pr_created: { label: 'PR Criado', color: 'bg-cyan-100 text-cyan-800', icon: <ExternalLink className="h-4 w-4" /> },
|
||||||
|
accepted: { label: 'Aceito', color: 'bg-green-100 text-green-800', icon: <CheckCircle className="h-4 w-4" /> },
|
||||||
|
rejected: { label: 'Rejeitado', color: 'bg-red-100 text-red-800', icon: <XCircle className="h-4 w-4" /> },
|
||||||
|
failed: { label: 'Falhou', color: 'bg-red-100 text-red-800', icon: <XCircle className="h-4 w-4" /> },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Issues() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<IssueStatus | ''>('')
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['issues', statusFilter],
|
||||||
|
queryFn: () => api.get('/api/issues', {
|
||||||
|
params: { status: statusFilter || undefined }
|
||||||
|
}).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const issues: Issue[] = data?.items || []
|
||||||
|
|
||||||
|
const filteredIssues = issues.filter(issue =>
|
||||||
|
issue.jira_key.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
issue.title.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
|
<div className="flex-1 min-w-[200px] relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por JIRA key ou título..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value as IssueStatus | '')}
|
||||||
|
className="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos os status</option>
|
||||||
|
{Object.entries(statusConfig).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>{config.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Atualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Activity className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
) : filteredIssues.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 py-16">
|
||||||
|
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-lg font-medium">Nenhuma issue encontrada</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Issues do JIRA aparecerão aqui após configurar os webhooks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">JIRA Key</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Título</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Módulo</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Confiança</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{filteredIssues.map(issue => {
|
||||||
|
const status = statusConfig[issue.status]
|
||||||
|
return (
|
||||||
|
<tr key={issue.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-blue-600 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{issue.jira_key}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-900 max-w-md truncate">
|
||||||
|
{issue.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-500">
|
||||||
|
{issue.module || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={clsx('inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium', status.color)}>
|
||||||
|
{status.icon}
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{issue.confidence ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${issue.confidence * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{(issue.confidence * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="text-blue-600 hover:text-blue-800 text-sm">
|
||||||
|
Ver
|
||||||
|
</button>
|
||||||
|
{issue.pr_url && (
|
||||||
|
<a
|
||||||
|
href={issue.pr_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-green-600 hover:text-green-800 text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
PR <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Brain,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
Activity,
|
||||||
|
BookOpen
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface Module {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
program_patterns: string[]
|
||||||
|
keywords: string[]
|
||||||
|
rules: string[]
|
||||||
|
restrictions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modules() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [editingModule, setEditingModule] = useState<Module | null>(null)
|
||||||
|
const [isNew, setIsNew] = useState(false)
|
||||||
|
|
||||||
|
const { data: modules, isLoading } = useQuery({
|
||||||
|
queryKey: ['modules'],
|
||||||
|
queryFn: () => api.get('/api/config/modules').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (module: Module) =>
|
||||||
|
isNew
|
||||||
|
? api.post('/api/config/modules', module)
|
||||||
|
: api.put(`/api/config/modules/${module.name}`, module),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['modules'] })
|
||||||
|
setEditingModule(null)
|
||||||
|
setIsNew(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => api.delete(`/api/config/modules/${name}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['modules'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const modulesList: Module[] = modules || []
|
||||||
|
|
||||||
|
const handleNewModule = () => {
|
||||||
|
setEditingModule({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
program_patterns: [],
|
||||||
|
keywords: [],
|
||||||
|
rules: [],
|
||||||
|
restrictions: [],
|
||||||
|
})
|
||||||
|
setIsNew(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Módulos de regras de negócio para contextualização da IA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleNewModule}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Novo Módulo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modules List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Activity className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
) : modulesList.length === 0 && !editingModule ? (
|
||||||
|
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||||
|
<Brain className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Nenhum módulo configurado</h3>
|
||||||
|
<p className="text-gray-500 mt-2 max-w-md mx-auto">
|
||||||
|
Módulos definem regras de negócio específicas que a IA usa para entender o contexto do sistema
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleNewModule}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Criar primeiro módulo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{modulesList.map(module => (
|
||||||
|
<div key={module.name} className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Brain className="h-8 w-8 text-purple-500" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">{module.name}</h3>
|
||||||
|
<p className="text-gray-500">{module.description || 'Sem descrição'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingModule(module); setIsNew(false); }}
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover módulo ${module.name}?`)) {
|
||||||
|
deleteMutation.mutate(module.name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Padrões de programa:</span>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{module.program_patterns.map(p => (
|
||||||
|
<span key={p} className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs">
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{module.program_patterns.length === 0 && <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Keywords:</span>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{module.keywords.slice(0, 5).map(k => (
|
||||||
|
<span key={k} className="px-2 py-0.5 bg-purple-100 text-purple-800 rounded text-xs">
|
||||||
|
{k}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{module.keywords.length > 5 && (
|
||||||
|
<span className="text-gray-400 text-xs">+{module.keywords.length - 5}</span>
|
||||||
|
)}
|
||||||
|
{module.keywords.length === 0 && <span className="text-gray-400">-</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit/Create Modal */}
|
||||||
|
{editingModule && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{isNew ? 'Novo Módulo' : `Editar: ${editingModule.name}`}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setEditingModule(null)} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Nome *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingModule.name}
|
||||||
|
onChange={e => setEditingModule({ ...editingModule, name: e.target.value })}
|
||||||
|
disabled={!isNew}
|
||||||
|
placeholder="ACQ-Auth"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Descrição</label>
|
||||||
|
<textarea
|
||||||
|
value={editingModule.description || ''}
|
||||||
|
onChange={e => setEditingModule({ ...editingModule, description: e.target.value })}
|
||||||
|
placeholder="Módulo de autorização de transações..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Padrões de Programa (um por linha)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editingModule.program_patterns.join('\n')}
|
||||||
|
onChange={e => setEditingModule({
|
||||||
|
...editingModule,
|
||||||
|
program_patterns: e.target.value.split('\n').filter(Boolean)
|
||||||
|
})}
|
||||||
|
placeholder="AUTH* ACQAUTH* *AUTOR*"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Keywords (uma por linha)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editingModule.keywords.join('\n')}
|
||||||
|
onChange={e => setEditingModule({
|
||||||
|
...editingModule,
|
||||||
|
keywords: e.target.value.split('\n').filter(Boolean)
|
||||||
|
})}
|
||||||
|
placeholder="autorização ISO8583 network timeout"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Regras de Negócio (uma por linha)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editingModule.rules.join('\n')}
|
||||||
|
onChange={e => setEditingModule({
|
||||||
|
...editingModule,
|
||||||
|
rules: e.target.value.split('\n').filter(Boolean)
|
||||||
|
})}
|
||||||
|
placeholder="Sempre validar CVV antes de enviar para rede Timeout de rede = 30 segundos"
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Restrições (uma por linha)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editingModule.restrictions.join('\n')}
|
||||||
|
onChange={e => setEditingModule({
|
||||||
|
...editingModule,
|
||||||
|
restrictions: e.target.value.split('\n').filter(Boolean)
|
||||||
|
})}
|
||||||
|
placeholder="Não alterar formato de mensagem ISO sem aprovação Não modificar copybook ISOMSGO1"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-gray-200 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingModule(null)}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate(editingModule)}
|
||||||
|
disabled={!editingModule.name || saveMutation.isPending}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{saveMutation.isPending ? 'Salvando...' : 'Salvar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
GitBranch,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Activity,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
interface Repository {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
ai_fork_name?: string
|
||||||
|
indexed: boolean
|
||||||
|
last_sync?: string
|
||||||
|
file_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Repositories() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [newRepo, setNewRepo] = useState({ url: '', name: '', ai_fork_name: '' })
|
||||||
|
|
||||||
|
const { data: repos, isLoading } = useQuery({
|
||||||
|
queryKey: ['repositories'],
|
||||||
|
queryFn: () => api.get('/api/config/repositories').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (repo: Partial<Repository>) => api.post('/api/config/repositories', repo),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repositories'] })
|
||||||
|
setShowAddModal(false)
|
||||||
|
setNewRepo({ url: '', name: '', ai_fork_name: '' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const reindexMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => api.post(`/api/config/repositories/${name}/reindex`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repositories'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => api.delete(`/api/config/repositories/${name}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repositories'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const repositories: Repository[] = repos || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Repositórios Bitbucket configurados para análise de código
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Adicionar Repositório
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repositories Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Activity className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
) : repositories.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||||
|
<GitBranch className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Nenhum repositório configurado</h3>
|
||||||
|
<p className="text-gray-500 mt-2">
|
||||||
|
Adicione repositórios do Bitbucket para indexar o código fonte
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Adicionar primeiro repositório
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{repositories.map(repo => (
|
||||||
|
<div key={repo.name} className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<GitBranch className="h-8 w-8 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{repo.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 truncate max-w-[200px]">{repo.url}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={clsx(
|
||||||
|
'px-2 py-1 rounded-full text-xs font-medium',
|
||||||
|
repo.indexed ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||||||
|
)}>
|
||||||
|
{repo.indexed ? 'Indexado' : 'Pendente'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Arquivos:</span>
|
||||||
|
<span className="font-medium">{repo.file_count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{repo.ai_fork_name && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Fork IA:</span>
|
||||||
|
<span className="font-medium">{repo.ai_fork_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{repo.last_sync && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Última sincr.:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(repo.last_sync).toLocaleDateString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => reindexMutation.mutate(repo.name)}
|
||||||
|
disabled={reindexMutation.isPending}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx('h-4 w-4', reindexMutation.isPending && 'animate-spin')} />
|
||||||
|
Re-indexar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover repositório ${repo.name}?`)) {
|
||||||
|
deleteMutation.mutate(repo.name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Adicionar Repositório</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
URL do Repositório *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRepo.url}
|
||||||
|
onChange={e => setNewRepo({ ...newRepo, url: e.target.value })}
|
||||||
|
placeholder="https://bitbucket.example.com/projects/XXX/repos/yyy"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nome *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRepo.name}
|
||||||
|
onChange={e => setNewRepo({ ...newRepo, name: e.target.value })}
|
||||||
|
placeholder="ACQ-MF"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nome do Fork para IA (opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newRepo.ai_fork_name}
|
||||||
|
onChange={e => setNewRepo({ ...newRepo, ai_fork_name: e.target.value })}
|
||||||
|
placeholder="ACQ-MF-AI"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Fork onde a IA criará branches e PRs (mantém o repo original intacto)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(false)}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addMutation.mutate(newRepo)}
|
||||||
|
disabled={!newRepo.url || !newRepo.name || addMutation.isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{addMutation.isPending ? 'Adicionando...' : 'Adicionar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
TestTube,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Activity,
|
||||||
|
Server,
|
||||||
|
Key,
|
||||||
|
Brain
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
interface IntegrationConfig {
|
||||||
|
jira_url?: string
|
||||||
|
jira_token?: string
|
||||||
|
jira_projects: string[]
|
||||||
|
bitbucket_url?: string
|
||||||
|
bitbucket_token?: string
|
||||||
|
llm_provider: 'openrouter' | 'azure'
|
||||||
|
azure_endpoint?: string
|
||||||
|
azure_key?: string
|
||||||
|
azure_model: string
|
||||||
|
openrouter_key?: string
|
||||||
|
openrouter_model: string
|
||||||
|
embedding_provider: 'local' | 'azure'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, boolean | null>>({})
|
||||||
|
|
||||||
|
const { data: config, isLoading } = useQuery({
|
||||||
|
queryKey: ['integrations'],
|
||||||
|
queryFn: () => api.get('/api/config/integrations').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<IntegrationConfig | null>(null)
|
||||||
|
|
||||||
|
// Initialize form when config loads
|
||||||
|
if (config && !formData) {
|
||||||
|
setFormData(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: IntegrationConfig) => api.put('/api/config/integrations', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['integrations'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const testConnection = async (service: string) => {
|
||||||
|
setTestResults(prev => ({ ...prev, [service]: null }))
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/api/config/integrations/test/${service}`)
|
||||||
|
setTestResults(prev => ({ ...prev, [service]: response.data.connected }))
|
||||||
|
} catch {
|
||||||
|
setTestResults(prev => ({ ...prev, [service]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !formData) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Activity className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateField = (field: keyof IntegrationConfig, value: string | string[]) => {
|
||||||
|
setFormData({ ...formData, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-6">
|
||||||
|
{/* JIRA */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-3">
|
||||||
|
<Server className="h-5 w-5 text-blue-500" />
|
||||||
|
<h3 className="text-lg font-semibold">JIRA Server</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">URL do Servidor</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.jira_url || ''}
|
||||||
|
onChange={e => updateField('jira_url', e.target.value)}
|
||||||
|
placeholder="https://jira.example.com"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Token de Acesso</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.jira_token || ''}
|
||||||
|
onChange={e => updateField('jira_token', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Projetos (separados por vírgula)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.jira_projects.join(', ')}
|
||||||
|
onChange={e => updateField('jira_projects', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||||
|
placeholder="ACQSUP, ICGSUP"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => testConnection('jira')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"
|
||||||
|
>
|
||||||
|
{testResults.jira === null ? (
|
||||||
|
<Activity className="h-4 w-4 animate-spin" />
|
||||||
|
) : testResults.jira ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : testResults.jira === false ? (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Testar Conexão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bitbucket */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-3">
|
||||||
|
<Server className="h-5 w-5 text-purple-500" />
|
||||||
|
<h3 className="text-lg font-semibold">Bitbucket Server</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">URL do Servidor</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.bitbucket_url || ''}
|
||||||
|
onChange={e => updateField('bitbucket_url', e.target.value)}
|
||||||
|
placeholder="https://bitbucket.example.com"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Token de Acesso</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.bitbucket_token || ''}
|
||||||
|
onChange={e => updateField('bitbucket_token', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => testConnection('bitbucket')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"
|
||||||
|
>
|
||||||
|
{testResults.bitbucket === null ? (
|
||||||
|
<Activity className="h-4 w-4 animate-spin" />
|
||||||
|
) : testResults.bitbucket ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : testResults.bitbucket === false ? (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Testar Conexão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LLM */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-3">
|
||||||
|
<Brain className="h-5 w-5 text-green-500" />
|
||||||
|
<h3 className="text-lg font-semibold">LLM (Modelo de IA)</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Provedor</label>
|
||||||
|
<select
|
||||||
|
value={formData.llm_provider}
|
||||||
|
onChange={e => updateField('llm_provider', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="openrouter">OpenRouter (Dev)</option>
|
||||||
|
<option value="azure">Azure OpenAI (Prod)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.llm_provider === 'azure' ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Azure Endpoint</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.azure_endpoint || ''}
|
||||||
|
onChange={e => updateField('azure_endpoint', e.target.value)}
|
||||||
|
placeholder="https://your-resource.openai.azure.com"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Azure API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.azure_key || ''}
|
||||||
|
onChange={e => updateField('azure_key', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Modelo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.azure_model}
|
||||||
|
onChange={e => updateField('azure_model', e.target.value)}
|
||||||
|
placeholder="gpt-4o"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">OpenRouter API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.openrouter_key || ''}
|
||||||
|
onChange={e => updateField('openrouter_key', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Modelo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.openrouter_model}
|
||||||
|
onChange={e => updateField('openrouter_model', e.target.value)}
|
||||||
|
placeholder="meta-llama/llama-3.3-70b-instruct:free"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => testConnection('llm')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"
|
||||||
|
>
|
||||||
|
{testResults.llm === null ? (
|
||||||
|
<Activity className="h-4 w-4 animate-spin" />
|
||||||
|
) : testResults.llm ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : testResults.llm === false ? (
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Testar Conexão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Embeddings */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-3">
|
||||||
|
<Key className="h-5 w-5 text-orange-500" />
|
||||||
|
<h3 className="text-lg font-semibold">Embeddings</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Provedor</label>
|
||||||
|
<select
|
||||||
|
value={formData.embedding_provider}
|
||||||
|
onChange={e => updateField('embedding_provider', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="local">Local (MiniLM-L6-v2)</option>
|
||||||
|
<option value="azure">Azure OpenAI</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{formData.embedding_provider === 'local'
|
||||||
|
? 'Modelo local, sem custo adicional, ideal para desenvolvimento'
|
||||||
|
: 'Usa o mesmo endpoint Azure configurado acima'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate(formData)}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-5 w-5" />
|
||||||
|
{saveMutation.isPending ? 'Salvando...' : 'Salvar Configurações'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue