3013 lines
122 KiB
TypeScript
3013 lines
122 KiB
TypeScript
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||
import { useState, useEffect } from "react";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import {
|
||
LayoutDashboard,
|
||
Database,
|
||
BarChart3,
|
||
FileText,
|
||
Plus,
|
||
Play,
|
||
Trash2,
|
||
Settings,
|
||
Loader2,
|
||
CheckCircle,
|
||
XCircle,
|
||
Clock,
|
||
Zap,
|
||
HardDrive,
|
||
Table,
|
||
PieChart,
|
||
LineChart,
|
||
TrendingUp,
|
||
Activity,
|
||
Archive,
|
||
Download,
|
||
RefreshCw,
|
||
Search,
|
||
Filter,
|
||
Upload,
|
||
FileSpreadsheet,
|
||
GripVertical,
|
||
ArrowRight,
|
||
Columns,
|
||
Layers,
|
||
FileArchive,
|
||
MapPin,
|
||
Send,
|
||
Eye,
|
||
Save,
|
||
Bot,
|
||
X,
|
||
Sparkles,
|
||
MessageSquare,
|
||
} from "lucide-react";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
BarChart,
|
||
Bar,
|
||
LineChart as RechartsLineChart,
|
||
Line,
|
||
PieChart as RechartsPieChart,
|
||
Pie,
|
||
AreaChart,
|
||
Area,
|
||
XAxis,
|
||
YAxis,
|
||
CartesianGrid,
|
||
Tooltip,
|
||
Legend,
|
||
ResponsiveContainer,
|
||
Cell,
|
||
} from "recharts";
|
||
|
||
// ── Componente da aba Insights (Apache Superset) ──────────────────────────────
|
||
function SupersetAdvancedTab() {
|
||
const [selectedDashboard, setSelectedDashboard] = useState<string>("");
|
||
const [dashboards, setDashboards] = useState<Array<{ id: string; title: string }>>([]);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/superset/dashboards", { credentials: "include" })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (Array.isArray(data)) {
|
||
setDashboards(data.map((d: any) => ({ id: String(d.id), title: d.dashboard_title || d.title })));
|
||
if (data.length > 0) setSelectedDashboard(String(data[0].id));
|
||
}
|
||
})
|
||
.catch(() => setDashboards([]));
|
||
}, []);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-[#1f334d]">Arcádia Insights — Apache Superset</h2>
|
||
<p className="text-sm text-gray-500">SQL Lab avançado, 50+ tipos de gráfico, dashboards interativos</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{dashboards.length > 0 && (
|
||
<select
|
||
value={selectedDashboard}
|
||
onChange={e => setSelectedDashboard(e.target.value)}
|
||
className="text-sm border border-gray-200 rounded-lg px-3 py-2 bg-white text-[#1f334d] focus:outline-none focus:ring-2 focus:ring-[#c89b3c]"
|
||
>
|
||
{dashboards.map(d => (
|
||
<option key={d.id} value={d.id}>{d.title}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<a
|
||
href="/superset"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1f334d] text-white rounded-lg hover:bg-[#2a4466] transition-colors text-sm"
|
||
>
|
||
<ArrowRight className="w-4 h-4" /> Abrir completo
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedDashboard ? (
|
||
<SupersetDashboard
|
||
dashboardId={selectedDashboard}
|
||
height="calc(100vh - 320px)"
|
||
showOpenLink={false}
|
||
/>
|
||
) : (
|
||
<div className="rounded-xl overflow-hidden border border-[#c89b3c]/20 bg-white shadow-sm" style={{ height: "calc(100vh - 320px)" }}>
|
||
<iframe
|
||
src="/superset"
|
||
className="w-full h-full border-0"
|
||
title="Arcádia Insights — Apache Superset"
|
||
allow="fullscreen"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface BiStats {
|
||
dataSources: number;
|
||
datasets: number;
|
||
charts: number;
|
||
dashboards: number;
|
||
backupJobs: number;
|
||
}
|
||
|
||
interface DataSource {
|
||
id: number;
|
||
name: string;
|
||
type: string;
|
||
host: string;
|
||
database: string;
|
||
isActive: string;
|
||
lastTestedAt: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface Dataset {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
queryType: string;
|
||
tableName: string | null;
|
||
sqlQuery: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface BiChart {
|
||
id: number;
|
||
name: string;
|
||
chartType: string;
|
||
datasetId: number;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface BiDashboard {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface BackupJob {
|
||
id: number;
|
||
name: string;
|
||
backupType: string;
|
||
dataSource: { name: string; type: string } | null;
|
||
isActive: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface BackupArtifact {
|
||
id: number;
|
||
filename: string;
|
||
fileSize: number | null;
|
||
status: string;
|
||
jobName: string;
|
||
startedAt: string;
|
||
completedAt: string | null;
|
||
}
|
||
|
||
interface StagedTable {
|
||
id: number;
|
||
name: string;
|
||
sourceType: string;
|
||
sourceFile: string | null;
|
||
tableName: string;
|
||
columns: string | null;
|
||
rowCount: number;
|
||
status: string;
|
||
targetErp: string | null;
|
||
description: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface StagingStats {
|
||
totalTables: number;
|
||
totalRecords: number;
|
||
mappedTables: number;
|
||
pendingMigrations: number;
|
||
completedMigrations: number;
|
||
bySourceType: Record<string, number>;
|
||
byTargetErp: Record<string, number>;
|
||
}
|
||
|
||
interface ErpTarget {
|
||
id: string;
|
||
name: string;
|
||
entities: string[];
|
||
}
|
||
|
||
async function fetchStats(): Promise<BiStats> {
|
||
const res = await fetch("/api/bi/stats", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch stats");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchDataSources(): Promise<DataSource[]> {
|
||
const res = await fetch("/api/bi/data-sources", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchDatasets(): Promise<Dataset[]> {
|
||
const res = await fetch("/api/bi/datasets", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchCharts(): Promise<BiChart[]> {
|
||
const res = await fetch("/api/bi/charts", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchDashboards(): Promise<BiDashboard[]> {
|
||
const res = await fetch("/api/bi/dashboards", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchBackupJobs(): Promise<BackupJob[]> {
|
||
const res = await fetch("/api/bi/backup-jobs", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchBackupArtifacts(): Promise<BackupArtifact[]> {
|
||
const res = await fetch("/api/bi/backup-artifacts", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchTables(): Promise<string[]> {
|
||
const res = await fetch("/api/bi/tables", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchStagedTables(): Promise<StagedTable[]> {
|
||
const res = await fetch("/api/staging/tables", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchStagingStats(): Promise<StagingStats> {
|
||
const res = await fetch("/api/staging/stats", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchErpTargets(): Promise<ErpTarget[]> {
|
||
const res = await fetch("/api/staging/erp-targets", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed to fetch");
|
||
return res.json();
|
||
}
|
||
|
||
function formatBytes(bytes: number | null): string {
|
||
if (!bytes) return "0 B";
|
||
const k = 1024;
|
||
const sizes = ["B", "KB", "MB", "GB"];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||
}
|
||
|
||
function OverviewTab() {
|
||
const { data: stats, isLoading } = useQuery({ queryKey: ["bi-stats"], queryFn: fetchStats });
|
||
const { data: artifacts = [] } = useQuery({ queryKey: ["backup-artifacts"], queryFn: fetchBackupArtifacts });
|
||
|
||
if (isLoading) {
|
||
return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-[#c89b3c]" /></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-5 gap-4">
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-blue-500/20">
|
||
<Database className="w-5 h-5 text-blue-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.dataSources || 0}</p>
|
||
<p className="text-xs text-white/50">Fontes de Dados</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-purple-500/20">
|
||
<Table className="w-5 h-5 text-purple-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.datasets || 0}</p>
|
||
<p className="text-xs text-white/50">Datasets</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-green-500/20">
|
||
<BarChart3 className="w-5 h-5 text-green-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.charts || 0}</p>
|
||
<p className="text-xs text-white/50">Gráficos</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-[#c89b3c]/20">
|
||
<LayoutDashboard className="w-5 h-5 text-[#c89b3c]" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.dashboards || 0}</p>
|
||
<p className="text-xs text-white/50">Dashboards</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-orange-500/20">
|
||
<Archive className="w-5 h-5 text-orange-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.backupJobs || 0}</p>
|
||
<p className="text-xs text-white/50">Backups</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||
<Activity className="w-5 h-5 text-[#c89b3c]" />
|
||
Atividade Recente
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{artifacts.slice(0, 5).map((a) => (
|
||
<div key={a.id} className="flex items-center justify-between text-sm">
|
||
<div className="flex items-center gap-2">
|
||
{a.status === "completed" ? (
|
||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||
) : a.status === "running" ? (
|
||
<Loader2 className="w-4 h-4 text-yellow-400 animate-spin" />
|
||
) : (
|
||
<XCircle className="w-4 h-4 text-red-400" />
|
||
)}
|
||
<span className="text-white/70">{a.jobName}</span>
|
||
</div>
|
||
<span className="text-white/40">{formatBytes(a.fileSize)}</span>
|
||
</div>
|
||
))}
|
||
{artifacts.length === 0 && (
|
||
<p className="text-white/40 text-center py-4">Nenhuma atividade recente</p>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||
<TrendingUp className="w-5 h-5 text-[#c89b3c]" />
|
||
Início Rápido
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
<Button variant="outline" className="w-full justify-start text-white/70 border-white/10 hover:bg-white/5">
|
||
<Database className="w-4 h-4 mr-2" /> Conectar Fonte de Dados
|
||
</Button>
|
||
<Button variant="outline" className="w-full justify-start text-white/70 border-white/10 hover:bg-white/5">
|
||
<FileText className="w-4 h-4 mr-2" /> Criar Nova Consulta
|
||
</Button>
|
||
<Button variant="outline" className="w-full justify-start text-white/70 border-white/10 hover:bg-white/5">
|
||
<BarChart3 className="w-4 h-4 mr-2" /> Criar Gráfico
|
||
</Button>
|
||
<Button variant="outline" className="w-full justify-start text-white/70 border-white/10 hover:bg-white/5">
|
||
<Archive className="w-4 h-4 mr-2" /> Configurar Backup
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface InternalTable {
|
||
name: string;
|
||
columnCount: number;
|
||
sizeBytes: number;
|
||
category: string;
|
||
description: string;
|
||
}
|
||
|
||
function DataSourcesTab() {
|
||
const queryClient = useQueryClient();
|
||
const { data: sources = [], isLoading } = useQuery({ queryKey: ["data-sources"], queryFn: fetchDataSources });
|
||
const { data: internalTables = [] } = useQuery<InternalTable[]>({
|
||
queryKey: ["internal-tables"],
|
||
queryFn: async () => {
|
||
const res = await fetch("/api/bi/internal-tables", { credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed");
|
||
return res.json();
|
||
},
|
||
});
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [name, setName] = useState("");
|
||
const [type, setType] = useState("postgresql");
|
||
const [host, setHost] = useState("");
|
||
const [port, setPort] = useState("");
|
||
const [database, setDatabase] = useState("");
|
||
const [activeSourceType, setActiveSourceType] = useState<"external" | "internal">("internal");
|
||
const [searchInternal, setSearchInternal] = useState("");
|
||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: async (data: any) => {
|
||
const res = await fetch("/api/bi/data-sources", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(data),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["data-sources"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
setShowAdd(false);
|
||
setName("");
|
||
setHost("");
|
||
setPort("");
|
||
setDatabase("");
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
await fetch(`/api/bi/data-sources/${id}`, { method: "DELETE", credentials: "include" });
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["data-sources"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
},
|
||
});
|
||
|
||
const testMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
const res = await fetch(`/api/bi/data-sources/${id}/test`, { method: "POST", credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["data-sources"] });
|
||
},
|
||
});
|
||
|
||
const createDatasetFromTable = useMutation({
|
||
mutationFn: async (tableName: string) => {
|
||
const res = await fetch(`/api/bi/internal-tables/${tableName}/create-dataset`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name: tableName }),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["datasets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
},
|
||
});
|
||
|
||
const categories = ["all", ...Array.from(new Set(internalTables.map(t => t.category)))];
|
||
const filteredTables = internalTables.filter(t => {
|
||
const matchesSearch = t.name.toLowerCase().includes(searchInternal.toLowerCase()) ||
|
||
t.description.toLowerCase().includes(searchInternal.toLowerCase());
|
||
const matchesCategory = selectedCategory === "all" || t.category === selectedCategory;
|
||
return matchesSearch && matchesCategory;
|
||
});
|
||
|
||
if (isLoading) {
|
||
return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-[#c89b3c]" /></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-center">
|
||
<div className="flex items-center gap-4">
|
||
<h2 className="text-lg font-medium text-white">Fontes de Dados</h2>
|
||
<div className="flex bg-[#162638] rounded-lg p-1">
|
||
<button
|
||
onClick={() => setActiveSourceType("internal")}
|
||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${activeSourceType === "internal" ? "bg-[#c89b3c] text-[#1f334d]" : "text-white/70 hover:text-white"}`}
|
||
>
|
||
<Table className="w-4 h-4 inline mr-1" /> Tabelas Internas
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveSourceType("external")}
|
||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${activeSourceType === "external" ? "bg-[#c89b3c] text-[#1f334d]" : "text-white/70 hover:text-white"}`}
|
||
>
|
||
<Database className="w-4 h-4 inline mr-1" /> Conexões Externas
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<Dialog open={showAdd} onOpenChange={setShowAdd}>
|
||
<DialogTrigger asChild>
|
||
<Button className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]" data-testid="add-datasource">
|
||
<Plus className="w-4 h-4 mr-2" /> Nova Fonte
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="bg-[#1f334d] border-[#c89b3c]/30 text-white">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-[#c89b3c]">Nova Fonte de Dados</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 mt-4">
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Nome</label>
|
||
<Input value={name} onChange={(e) => setName(e.target.value)} className="bg-[#162638] border-white/20 text-white" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Tipo</label>
|
||
<Select value={type} onValueChange={setType}>
|
||
<SelectTrigger className="bg-[#162638] border-white/20 text-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#162638] border-white/20">
|
||
<SelectItem value="postgresql" className="text-white">PostgreSQL</SelectItem>
|
||
<SelectItem value="mysql" className="text-white">MySQL</SelectItem>
|
||
<SelectItem value="mongodb" className="text-white">MongoDB</SelectItem>
|
||
<SelectItem value="internal" className="text-white">Banco Interno</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Host</label>
|
||
<Input value={host} onChange={(e) => setHost(e.target.value)} className="bg-[#162638] border-white/20 text-white" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Porta</label>
|
||
<Input value={port} onChange={(e) => setPort(e.target.value)} type="number" className="bg-[#162638] border-white/20 text-white" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Banco de Dados</label>
|
||
<Input value={database} onChange={(e) => setDatabase(e.target.value)} className="bg-[#162638] border-white/20 text-white" />
|
||
</div>
|
||
<Button
|
||
onClick={() => createMutation.mutate({ name, type, host, port: port ? parseInt(port) : undefined, database })}
|
||
disabled={!name || createMutation.isPending}
|
||
className="w-full bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]"
|
||
>
|
||
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : "Criar"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
|
||
{activeSourceType === "internal" ? (
|
||
<div className="space-y-4">
|
||
<div className="flex gap-3 items-center">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
|
||
<Input
|
||
placeholder="Buscar tabelas..."
|
||
value={searchInternal}
|
||
onChange={(e) => setSearchInternal(e.target.value)}
|
||
className="pl-10 bg-[#162638] border-white/20 text-white"
|
||
/>
|
||
</div>
|
||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||
<SelectTrigger className="w-48 bg-[#162638] border-white/20 text-white">
|
||
<SelectValue placeholder="Categoria" />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#162638] border-white/20">
|
||
{categories.map(cat => (
|
||
<SelectItem key={cat} value={cat} className="text-white">
|
||
{cat === "all" ? "Todas as categorias" : cat}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<Card className="bg-[#162638] border-[#c89b3c]/20">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-[#c89b3c] flex items-center gap-2">
|
||
<HardDrive className="w-4 h-4" />
|
||
Repositório de Dados do Sistema ({filteredTables.length} tabelas)
|
||
</CardTitle>
|
||
<p className="text-xs text-white/50">
|
||
Todos os dados do sistema disponíveis para análise pelo Cientista e Agente Manus
|
||
</p>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ScrollArea className="h-[400px]">
|
||
<div className="space-y-1">
|
||
{filteredTables.map((table, idx) => (
|
||
<div
|
||
key={`${table.name}-${idx}`}
|
||
className="flex items-center justify-between p-3 rounded-lg hover:bg-white/5 border border-transparent hover:border-[#c89b3c]/20 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-lg bg-[#c89b3c]/10">
|
||
<Table className="w-4 h-4 text-[#c89b3c]" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-white">{table.name}</p>
|
||
<p className="text-xs text-white/50">{table.description}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant="outline" className="text-white/60 border-white/20 text-xs">
|
||
{table.category}
|
||
</Badge>
|
||
<span className="text-xs text-white/40">{table.columnCount} cols</span>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => createDatasetFromTable.mutate(table.name)}
|
||
disabled={createDatasetFromTable.isPending}
|
||
className="bg-[#c89b3c]/20 hover:bg-[#c89b3c]/30 text-[#c89b3c] text-xs"
|
||
>
|
||
{createDatasetFromTable.isPending ? (
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<>
|
||
<Plus className="w-3 h-3 mr-1" /> Criar Dataset
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{sources.length === 0 ? (
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardContent className="p-8 text-center">
|
||
<Database className="w-12 h-12 text-[#c89b3c]/50 mx-auto mb-4" />
|
||
<p className="text-white/50">Nenhuma fonte de dados externa configurada</p>
|
||
<p className="text-xs text-white/30 mt-2">Clique em "Nova Fonte" para conectar um banco externo</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{sources.map((source) => (
|
||
<Card key={source.id} className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className={`p-2 rounded-lg ${source.type === "mongodb" ? "bg-green-500/20" : source.type === "mysql" ? "bg-blue-500/20" : "bg-purple-500/20"}`}>
|
||
<Database className={`w-5 h-5 ${source.type === "mongodb" ? "text-green-400" : source.type === "mysql" ? "text-blue-400" : "text-purple-400"}`} />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-medium text-white">{source.name}</h3>
|
||
<p className="text-sm text-white/50">{source.type.toUpperCase()} - {source.host || "localhost"}</p>
|
||
</div>
|
||
</div>
|
||
<Badge variant={source.isActive === "true" ? "default" : "secondary"} className={source.isActive === "true" ? "bg-green-500/20 text-green-400" : ""}>
|
||
{source.isActive === "true" ? "Ativo" : "Inativo"}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex gap-2 mt-4">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => testMutation.mutate(source.id)}
|
||
disabled={testMutation.isPending}
|
||
className="text-white/70 border-white/10"
|
||
>
|
||
{testMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||
<span className="ml-1">Testar</span>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => deleteMutation.mutate(source.id)}
|
||
className="text-red-400 border-red-400/20 hover:bg-red-400/10"
|
||
>
|
||
<Trash2 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DatasetCard({ dataset, onDelete }: { dataset: Dataset; onDelete: () => void }) {
|
||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||
const [analysisResult, setAnalysisResult] = useState<string | null>(null);
|
||
const queryClient = useQueryClient();
|
||
|
||
const runAIAction = async (action: string) => {
|
||
setIsAnalyzing(true);
|
||
setAnalysisResult(null);
|
||
|
||
const prompts: Record<string, string> = {
|
||
analyze: `[ANÁLISE BI] Dataset: "${dataset.name}" (ID: ${dataset.id}), Tabela: ${dataset.tableName || 'N/A'}
|
||
INSTRUÇÕES: Use bi_execute_query com datasetId ${dataset.id} para obter os dados. Analise estatísticas descritivas, identifique padrões e gere um resumo executivo com insights.`,
|
||
chart: `[GRÁFICO BI] Dataset: "${dataset.name}" (ID: ${dataset.id}), Tabela: ${dataset.tableName || 'N/A'}
|
||
INSTRUÇÕES: Primeiro use bi_execute_query com datasetId ${dataset.id} para obter os dados. Depois use bi_create_chart para criar um gráfico apropriado.`,
|
||
patterns: `[PADRÕES BI] Dataset: "${dataset.name}" (ID: ${dataset.id}), Tabela: ${dataset.tableName || 'N/A'}
|
||
INSTRUÇÕES: Use bi_execute_query com datasetId ${dataset.id} para obter os dados. Identifique correlações, tendências e anomalias.`,
|
||
};
|
||
|
||
try {
|
||
const res = await fetch("/api/manus/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ prompt: prompts[action] }),
|
||
credentials: "include",
|
||
});
|
||
|
||
if (!res.ok) throw new Error("Falha");
|
||
const { runId } = await res.json();
|
||
|
||
let attempts = 0;
|
||
while (attempts < 60) {
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
const statusRes = await fetch(`/api/manus/runs/${runId}`, { credentials: "include" });
|
||
const run = await statusRes.json();
|
||
|
||
if (run.status === 'completed' || run.status === 'stopped') {
|
||
const lastStep = run.steps?.[run.steps.length - 1];
|
||
setAnalysisResult(lastStep?.toolOutput || run.result || "Análise concluída.");
|
||
queryClient.invalidateQueries({ queryKey: ["bi-charts"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
break;
|
||
}
|
||
attempts++;
|
||
}
|
||
} catch (error) {
|
||
setAnalysisResult("Erro ao processar. Tente novamente.");
|
||
} finally {
|
||
setIsAnalyzing(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-lg bg-purple-500/20">
|
||
{dataset.queryType === "sql" ? <FileText className="w-5 h-5 text-purple-400" /> : <Table className="w-5 h-5 text-purple-400" />}
|
||
</div>
|
||
<div>
|
||
<h3 className="font-medium text-white">{dataset.name}</h3>
|
||
<p className="text-sm text-white/50">{dataset.queryType === "sql" ? "SQL Query" : dataset.tableName}</p>
|
||
</div>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-400 border-red-400/20 hover:bg-red-400/10">
|
||
<Trash2 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex gap-2 flex-wrap">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => runAIAction('analyze')}
|
||
disabled={isAnalyzing}
|
||
className="text-[#c89b3c] border-[#c89b3c]/30 hover:bg-[#c89b3c]/10"
|
||
>
|
||
{isAnalyzing ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <Sparkles className="w-3 h-3 mr-1" />}
|
||
Analisar com IA
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => runAIAction('chart')}
|
||
disabled={isAnalyzing}
|
||
className="text-blue-400 border-blue-400/30 hover:bg-blue-400/10"
|
||
>
|
||
<BarChart3 className="w-3 h-3 mr-1" /> Criar Gráfico
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => runAIAction('patterns')}
|
||
disabled={isAnalyzing}
|
||
className="text-green-400 border-green-400/30 hover:bg-green-400/10"
|
||
>
|
||
<Search className="w-3 h-3 mr-1" /> Detectar Padrões
|
||
</Button>
|
||
</div>
|
||
|
||
{isAnalyzing && (
|
||
<div className="mt-3 p-3 bg-white/5 rounded-lg flex items-center gap-2">
|
||
<Loader2 className="w-4 h-4 text-[#c89b3c] animate-spin" />
|
||
<span className="text-white/70 text-sm">Analisando dados...</span>
|
||
</div>
|
||
)}
|
||
|
||
{analysisResult && !isAnalyzing && (
|
||
<div className="mt-3 p-3 bg-white/5 rounded-lg">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-[#c89b3c] text-xs font-medium">Resultado da Análise</span>
|
||
<button onClick={() => setAnalysisResult(null)} className="text-white/50 hover:text-white">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
<pre className="text-white/80 text-xs whitespace-pre-wrap max-h-48 overflow-y-auto">{analysisResult}</pre>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function DataAssistant({ datasets }: { datasets: Dataset[] }) {
|
||
const [question, setQuestion] = useState("");
|
||
const [selectedDatasetId, setSelectedDatasetId] = useState<number | null>(null);
|
||
const [messages, setMessages] = useState<Array<{
|
||
type: 'user' | 'assistant';
|
||
content: string;
|
||
insights?: string[];
|
||
suggestedChart?: any;
|
||
datasetName?: string;
|
||
}>>([]);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const queryClient = useQueryClient();
|
||
|
||
const askQuestion = async () => {
|
||
if (!question.trim() || !selectedDatasetId) return;
|
||
|
||
const selectedDataset = datasets.find(d => d.id === selectedDatasetId);
|
||
const userMessage = { type: 'user' as const, content: question };
|
||
setMessages(prev => [...prev, userMessage]);
|
||
setQuestion("");
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
const res = await fetch("/api/bi/ai-analyze", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ question, datasetId: selectedDatasetId }),
|
||
credentials: "include",
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errData = await res.json().catch(() => ({ error: "Erro desconhecido" }));
|
||
throw new Error(errData.error || "Falha na análise");
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
setMessages(prev => [...prev, {
|
||
type: 'assistant',
|
||
content: data.answer || "Análise concluída.",
|
||
insights: data.insights,
|
||
suggestedChart: data.suggestedChart,
|
||
datasetName: data.datasetName,
|
||
}]);
|
||
} catch (error: any) {
|
||
setMessages(prev => [...prev, {
|
||
type: 'assistant',
|
||
content: `Desculpe, ocorreu um erro: ${error.message || "Tente novamente."}`,
|
||
}]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const createChart = async (suggestedChart: any) => {
|
||
if (!selectedDatasetId || !suggestedChart) return;
|
||
|
||
try {
|
||
const res = await fetch("/api/bi/ai-create-chart", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ datasetId: selectedDatasetId, chartConfig: suggestedChart }),
|
||
credentials: "include",
|
||
});
|
||
|
||
if (res.ok) {
|
||
queryClient.invalidateQueries({ queryKey: ["bi-charts"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
}
|
||
} catch (error) {
|
||
console.error("Error creating chart:", error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card className="bg-gradient-to-br from-[#1f334d] to-[#0f1a2a] border-[#c89b3c]/30">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-lg text-white flex items-center gap-2">
|
||
<Bot className="w-5 h-5 text-[#c89b3c]" />
|
||
Assistente de Dados
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex gap-2">
|
||
<Select
|
||
value={selectedDatasetId?.toString() || ""}
|
||
onValueChange={(v) => setSelectedDatasetId(parseInt(v))}
|
||
>
|
||
<SelectTrigger className="bg-white/10 border-white/20 text-white flex-1">
|
||
<SelectValue placeholder="Selecione um dataset" />
|
||
</SelectTrigger>
|
||
<SelectContent className="z-[9999] bg-[#1f334d] border-[#c89b3c]/30">
|
||
{datasets.length === 0 ? (
|
||
<div className="p-2 text-white/50 text-sm">Nenhum dataset disponível</div>
|
||
) : (
|
||
datasets.map((d) => (
|
||
<SelectItem
|
||
key={d.id}
|
||
value={d.id.toString()}
|
||
className="text-white hover:bg-[#c89b3c]/20 focus:bg-[#c89b3c]/20"
|
||
>
|
||
{d.name}
|
||
</SelectItem>
|
||
))
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<ScrollArea className="h-64 bg-black/20 rounded-lg p-3">
|
||
{messages.length === 0 ? (
|
||
<div className="h-full flex flex-col items-center justify-center text-white/40 text-sm">
|
||
<MessageSquare className="w-8 h-8 mb-2" />
|
||
<p>Faça uma pergunta sobre seus dados</p>
|
||
<p className="text-xs mt-1">Ex: "Qual é o total de vendas por cidade?"</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{messages.map((msg, idx) => (
|
||
<div key={idx} className={`flex ${msg.type === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||
<div className={`max-w-[85%] rounded-lg p-3 ${
|
||
msg.type === 'user'
|
||
? 'bg-[#c89b3c] text-black'
|
||
: 'bg-white/10 text-white'
|
||
}`}>
|
||
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||
|
||
{msg.insights && msg.insights.length > 0 && (
|
||
<div className="mt-2 pt-2 border-t border-white/10">
|
||
<p className="text-xs font-medium mb-1 text-[#c89b3c]">Insights:</p>
|
||
<ul className="text-xs space-y-1">
|
||
{msg.insights.map((insight, i) => (
|
||
<li key={i} className="flex items-start gap-1">
|
||
<span className="text-[#c89b3c]">•</span>
|
||
{insight}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{msg.suggestedChart && (
|
||
<div className="mt-2 pt-2 border-t border-white/10">
|
||
<Button
|
||
size="sm"
|
||
onClick={() => createChart(msg.suggestedChart)}
|
||
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-black text-xs"
|
||
>
|
||
<BarChart3 className="w-3 h-3 mr-1" />
|
||
Criar Gráfico: {msg.suggestedChart.title || msg.suggestedChart.type}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{isLoading && (
|
||
<div className="flex justify-start">
|
||
<div className="bg-white/10 rounded-lg p-3 flex items-center gap-2">
|
||
<Loader2 className="w-4 h-4 animate-spin text-[#c89b3c]" />
|
||
<span className="text-white/70 text-sm">Analisando...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</ScrollArea>
|
||
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="Faça uma pergunta sobre os dados..."
|
||
value={question}
|
||
onChange={(e) => setQuestion(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && askQuestion()}
|
||
className="bg-white/10 border-white/20 text-white placeholder:text-white/40"
|
||
disabled={!selectedDatasetId || isLoading}
|
||
/>
|
||
<Button
|
||
onClick={askQuestion}
|
||
disabled={!question.trim() || !selectedDatasetId || isLoading}
|
||
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-black"
|
||
>
|
||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const CHART_COLORS = ['#c89b3c', '#1f334d', '#4caf50', '#2196f3', '#ff9800', '#e91e63', '#9c27b0', '#00bcd4'];
|
||
|
||
function ChartViewer({ chart, datasets }: { chart: BiChart; datasets: Dataset[] }) {
|
||
const dataset = datasets.find((d: Dataset) => d.id === chart.datasetId);
|
||
const tableName = dataset?.tableName;
|
||
|
||
const { data: chartData = [], isLoading } = useQuery({
|
||
queryKey: ["chart-data", chart.id, tableName],
|
||
queryFn: async () => {
|
||
if (!tableName) return [];
|
||
const res = await fetch(`/api/bi/query`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ tableName, limit: 50 }),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) return [];
|
||
return res.json();
|
||
},
|
||
enabled: !!tableName,
|
||
});
|
||
|
||
const config = chart.config ? JSON.parse(chart.config) : {};
|
||
const xAxis = config.xAxis || chart.xAxis || Object.keys(chartData[0] || {})[0];
|
||
const yAxis = config.yAxis || chart.yAxis || Object.keys(chartData[0] || {})[1];
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="h-48 flex items-center justify-center">
|
||
<Loader2 className="w-6 h-6 animate-spin text-[#c89b3c]" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!chartData.length) {
|
||
return (
|
||
<div className="h-48 flex items-center justify-center bg-[#1f334d]/5 rounded-lg">
|
||
<p className="text-[#1f334d]/40 text-sm">Sem dados disponíveis</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const processedData = chartData.map((row: Record<string, unknown>) => {
|
||
const numValue = typeof row[yAxis] === 'string'
|
||
? parseFloat(String(row[yAxis]).replace(/[^\d.-]/g, '')) || 0
|
||
: Number(row[yAxis]) || 0;
|
||
return { ...row, [yAxis]: numValue };
|
||
});
|
||
|
||
const renderChart = () => {
|
||
switch (chart.chartType) {
|
||
case "bar":
|
||
return (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<BarChart data={processedData}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||
<XAxis dataKey={xAxis} tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Bar dataKey={yAxis} fill="#c89b3c" radius={[4, 4, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
case "line":
|
||
return (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<RechartsLineChart data={processedData}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||
<XAxis dataKey={xAxis} tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Line type="monotone" dataKey={yAxis} stroke="#c89b3c" strokeWidth={2} dot={{ fill: '#c89b3c' }} />
|
||
</RechartsLineChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
case "pie":
|
||
return (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<RechartsPieChart>
|
||
<Pie
|
||
data={processedData}
|
||
dataKey={yAxis}
|
||
nameKey={xAxis}
|
||
cx="50%"
|
||
cy="50%"
|
||
outerRadius={70}
|
||
label={({ name }) => name}
|
||
>
|
||
{processedData.map((_: unknown, index: number) => (
|
||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip />
|
||
</RechartsPieChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
case "area":
|
||
return (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<AreaChart data={processedData}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||
<XAxis dataKey={xAxis} tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Area type="monotone" dataKey={yAxis} stroke="#c89b3c" fill="#c89b3c" fillOpacity={0.3} />
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
default:
|
||
return (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<BarChart data={processedData}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||
<XAxis dataKey={xAxis} tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Bar dataKey={yAxis} fill="#c89b3c" radius={[4, 4, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
}
|
||
};
|
||
|
||
return <div className="mt-2">{renderChart()}</div>;
|
||
}
|
||
|
||
function ChartsTab() {
|
||
const queryClient = useQueryClient();
|
||
const { data: charts = [], isLoading } = useQuery({ queryKey: ["bi-charts"], queryFn: fetchCharts });
|
||
const { data: datasets = [] } = useQuery({ queryKey: ["datasets"], queryFn: fetchDatasets });
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
await fetch(`/api/bi/charts/${id}`, { method: "DELETE", credentials: "include" });
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["bi-charts"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
},
|
||
});
|
||
|
||
const getDatasetName = (datasetId: number | null) => {
|
||
if (!datasetId) return "N/A";
|
||
const dataset = datasets.find((d: Dataset) => d.id === datasetId);
|
||
return dataset?.name || `Dataset #${datasetId}`;
|
||
};
|
||
|
||
const getChartIcon = (type: string) => {
|
||
switch (type) {
|
||
case "bar": return <BarChart3 className="w-5 h-5" />;
|
||
case "line": return <LineChart className="w-5 h-5" />;
|
||
case "pie": return <PieChart className="w-5 h-5" />;
|
||
case "area": return <TrendingUp className="w-5 h-5" />;
|
||
default: return <BarChart3 className="w-5 h-5" />;
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-[#c89b3c]" /></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-xl font-semibold text-[#1f334d]">Gráficos Criados</h2>
|
||
<Badge variant="outline" className="bg-[#1f334d]/10">{charts.length} gráficos</Badge>
|
||
</div>
|
||
|
||
{charts.length === 0 ? (
|
||
<Card className="border-dashed border-2 border-[#1f334d]/20">
|
||
<CardContent className="py-12 text-center">
|
||
<BarChart3 className="w-12 h-12 mx-auto text-[#1f334d]/30 mb-4" />
|
||
<p className="text-[#1f334d]/60">Nenhum gráfico criado ainda.</p>
|
||
<p className="text-sm text-[#1f334d]/40 mt-2">
|
||
Use o assistente de IA para criar gráficos automaticamente a partir dos seus dados.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{charts.map((chart: BiChart) => (
|
||
<Card key={chart.id} className="bg-white border border-[#1f334d]/10 hover:shadow-lg transition-shadow">
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-[#1f334d] rounded-lg text-[#c89b3c]">
|
||
{getChartIcon(chart.chartType)}
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-base text-[#1f334d]">{chart.name}</CardTitle>
|
||
<p className="text-xs text-[#1f334d]/50">{getDatasetName(chart.datasetId)}</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => deleteMutation.mutate(chart.id)}
|
||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ChartViewer chart={chart} datasets={datasets} />
|
||
<div className="pt-3 mt-3 border-t border-[#1f334d]/10 flex items-center justify-between">
|
||
<Badge className="bg-[#c89b3c]/20 text-[#c89b3c]">{chart.chartType}</Badge>
|
||
<p className="text-xs text-[#1f334d]/40">
|
||
{new Date(chart.createdAt).toLocaleDateString('pt-BR')}
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DatasetsTab() {
|
||
const queryClient = useQueryClient();
|
||
const { data: datasets = [], isLoading } = useQuery({ queryKey: ["datasets"], queryFn: fetchDatasets });
|
||
const { data: tables = [] } = useQuery({ queryKey: ["tables"], queryFn: fetchTables });
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [name, setName] = useState("");
|
||
const [queryType, setQueryType] = useState("table");
|
||
const [tableName, setTableName] = useState("");
|
||
const [sqlQuery, setSqlQuery] = useState("");
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: async (data: any) => {
|
||
const res = await fetch("/api/bi/datasets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(data),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["datasets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
setShowAdd(false);
|
||
setName("");
|
||
setTableName("");
|
||
setSqlQuery("");
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
await fetch(`/api/bi/datasets/${id}`, { method: "DELETE", credentials: "include" });
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["datasets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
},
|
||
});
|
||
|
||
if (isLoading) {
|
||
return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-[#c89b3c]" /></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-lg font-medium text-white">Datasets / Consultas</h2>
|
||
<Dialog open={showAdd} onOpenChange={setShowAdd}>
|
||
<DialogTrigger asChild>
|
||
<Button className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]" data-testid="add-dataset">
|
||
<Plus className="w-4 h-4 mr-2" /> Novo Dataset
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="bg-[#1f334d] border-[#c89b3c]/30 text-white max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-[#c89b3c]">Novo Dataset</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 mt-4">
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Nome</label>
|
||
<Input value={name} onChange={(e) => setName(e.target.value)} className="bg-[#162638] border-white/20 text-white" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Tipo</label>
|
||
<Select value={queryType} onValueChange={setQueryType}>
|
||
<SelectTrigger className="bg-[#162638] border-white/20 text-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#162638] border-white/20">
|
||
<SelectItem value="table" className="text-white">Tabela</SelectItem>
|
||
<SelectItem value="sql" className="text-white">SQL Personalizado</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{queryType === "table" ? (
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Tabela</label>
|
||
<Select value={tableName} onValueChange={setTableName}>
|
||
<SelectTrigger className="bg-[#162638] border-white/20 text-white">
|
||
<SelectValue placeholder="Selecione uma tabela" />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#162638] border-white/20 max-h-60">
|
||
{tables.map((t) => (
|
||
<SelectItem key={t} value={t} className="text-white">{t}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Query SQL</label>
|
||
<textarea
|
||
value={sqlQuery}
|
||
onChange={(e) => setSqlQuery(e.target.value)}
|
||
placeholder="SELECT * FROM..."
|
||
className="w-full h-32 bg-[#162638] border border-white/20 rounded-lg p-2.5 text-white text-sm font-mono placeholder:text-white/40 focus:outline-none focus:border-[#c89b3c] resize-none"
|
||
/>
|
||
</div>
|
||
)}
|
||
<Button
|
||
onClick={() => createMutation.mutate({ name, queryType, tableName: queryType === "table" ? tableName : undefined, sqlQuery: queryType === "sql" ? sqlQuery : undefined })}
|
||
disabled={!name || createMutation.isPending}
|
||
className="w-full bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]"
|
||
>
|
||
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : "Criar Dataset"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
<div className="space-y-3">
|
||
{datasets.length === 0 ? (
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardContent className="p-8 text-center">
|
||
<Table className="w-12 h-12 text-[#c89b3c]/50 mx-auto mb-4" />
|
||
<p className="text-white/50">Nenhum dataset criado</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
datasets.map((ds) => (
|
||
<DatasetCard key={ds.id} dataset={ds} onDelete={() => deleteMutation.mutate(ds.id)} />
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<DataAssistant datasets={datasets} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BackupsTab() {
|
||
const queryClient = useQueryClient();
|
||
const { data: jobs = [], isLoading } = useQuery({ queryKey: ["backup-jobs"], queryFn: fetchBackupJobs });
|
||
const { data: artifacts = [] } = useQuery({ queryKey: ["backup-artifacts"], queryFn: fetchBackupArtifacts });
|
||
const { data: sources = [] } = useQuery({ queryKey: ["data-sources"], queryFn: fetchDataSources });
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [name, setName] = useState("");
|
||
const [dataSourceId, setDataSourceId] = useState("");
|
||
const [backupType, setBackupType] = useState("full");
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: async (data: any) => {
|
||
const res = await fetch("/api/bi/backup-jobs", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(data),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["backup-jobs"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
setShowAdd(false);
|
||
setName("");
|
||
setDataSourceId("");
|
||
},
|
||
});
|
||
|
||
const runMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
const res = await fetch(`/api/bi/backup-jobs/${id}/run`, { method: "POST", credentials: "include" });
|
||
if (!res.ok) throw new Error("Failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["backup-artifacts"] });
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
await fetch(`/api/bi/backup-jobs/${id}`, { method: "DELETE", credentials: "include" });
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["backup-jobs"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
},
|
||
});
|
||
|
||
if (isLoading) {
|
||
return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-[#c89b3c]" /></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-lg font-medium text-white">Backups de Banco de Dados</h2>
|
||
<Dialog open={showAdd} onOpenChange={setShowAdd}>
|
||
<DialogTrigger asChild>
|
||
<Button className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]" data-testid="add-backup">
|
||
<Plus className="w-4 h-4 mr-2" /> Novo Backup
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="bg-[#1f334d] border-[#c89b3c]/30 text-white">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-[#c89b3c]">Configurar Backup</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 mt-4">
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Nome</label>
|
||
<Input value={name} onChange={(e) => setName(e.target.value)} className="bg-[#162638] border-white/20 text-white" />
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Fonte de Dados</label>
|
||
<Select value={dataSourceId} onValueChange={setDataSourceId}>
|
||
<SelectTrigger className="bg-[#162638] border-white/20 text-white">
|
||
<SelectValue placeholder="Selecione" />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#162638] border-white/20">
|
||
{sources.map((s) => (
|
||
<SelectItem key={s.id} value={s.id.toString()} className="text-white">{s.name} ({s.type})</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Tipo de Backup</label>
|
||
<Select value={backupType} onValueChange={setBackupType}>
|
||
<SelectTrigger className="bg-[#162638] border-white/20 text-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#162638] border-white/20">
|
||
<SelectItem value="full" className="text-white">Completo (Dados + Schema)</SelectItem>
|
||
<SelectItem value="data" className="text-white">Apenas Dados</SelectItem>
|
||
<SelectItem value="schema" className="text-white">Apenas Schema</SelectItem>
|
||
<SelectItem value="incremental" className="text-white">Incremental</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Button
|
||
onClick={() => createMutation.mutate({ name, dataSourceId: parseInt(dataSourceId), backupType })}
|
||
disabled={!name || !dataSourceId || createMutation.isPending}
|
||
className="w-full bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]"
|
||
>
|
||
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : "Criar Job de Backup"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-medium text-white/70 uppercase tracking-wider">Jobs Configurados</h3>
|
||
{jobs.length === 0 ? (
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardContent className="p-6 text-center">
|
||
<Archive className="w-10 h-10 text-[#c89b3c]/50 mx-auto mb-3" />
|
||
<p className="text-white/50 text-sm">Nenhum job de backup</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
jobs.map((job) => (
|
||
<Card key={job.id} className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-lg bg-orange-500/20">
|
||
<HardDrive className="w-5 h-5 text-orange-400" />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-medium text-white">{job.name}</h3>
|
||
<p className="text-sm text-white/50">{job.dataSource?.name || "N/A"} - {job.backupType}</p>
|
||
</div>
|
||
</div>
|
||
<Badge variant={job.isActive === "true" ? "default" : "secondary"} className={job.isActive === "true" ? "bg-green-500/20 text-green-400" : ""}>
|
||
{job.isActive === "true" ? "Ativo" : "Inativo"}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex gap-2 mt-3">
|
||
<Button variant="outline" size="sm" onClick={() => runMutation.mutate(job.id)} disabled={runMutation.isPending} className="text-white/70 border-white/10">
|
||
{runMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||
<span className="ml-1">Executar</span>
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={() => deleteMutation.mutate(job.id)} className="text-red-400 border-red-400/20 hover:bg-red-400/10">
|
||
<Trash2 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<h3 className="text-sm font-medium text-white/70 uppercase tracking-wider">Histórico de Backups</h3>
|
||
<ScrollArea className="h-80">
|
||
{artifacts.length === 0 ? (
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardContent className="p-6 text-center">
|
||
<Download className="w-10 h-10 text-[#c89b3c]/50 mx-auto mb-3" />
|
||
<p className="text-white/50 text-sm">Nenhum backup realizado</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{artifacts.map((a) => (
|
||
<Card key={a.id} className="bg-[#162638] border-white/5">
|
||
<CardContent className="p-3 flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
{a.status === "completed" ? (
|
||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||
) : a.status === "running" ? (
|
||
<Loader2 className="w-5 h-5 text-yellow-400 animate-spin" />
|
||
) : (
|
||
<XCircle className="w-5 h-5 text-red-400" />
|
||
)}
|
||
<div>
|
||
<p className="text-sm text-white">{a.jobName}</p>
|
||
<p className="text-xs text-white/40">{new Date(a.startedAt).toLocaleString("pt-BR")}</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-sm text-white/70">{formatBytes(a.fileSize)}</p>
|
||
{a.status === "completed" && (
|
||
<Button variant="ghost" size="sm" className="h-6 text-xs text-[#c89b3c] hover:text-[#d4a94a]">
|
||
<Download className="w-3 h-3 mr-1" /> Download
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</ScrollArea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UploadTab() {
|
||
const queryClient = useQueryClient();
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [uploadedFile, setUploadedFile] = useState<any>(null);
|
||
const [tableName, setTableName] = useState("");
|
||
const [columnMappings, setColumnMappings] = useState<any[]>([]);
|
||
const [draggedColumn, setDraggedColumn] = useState<number | null>(null);
|
||
const [zipPrefix, setZipPrefix] = useState("");
|
||
const [selectedZipFiles, setSelectedZipFiles] = useState<string[]>([]);
|
||
const [selectedSqlTables, setSelectedSqlTables] = useState<string[]>([]);
|
||
const [sqlPrefix, setSqlPrefix] = useState("");
|
||
|
||
const uploadMutation = useMutation({
|
||
mutationFn: async (file: File) => {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
const res = await fetch("/api/bi/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Upload failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: (data) => {
|
||
console.log("[BI Upload] Response:", data);
|
||
setUploadedFile(data);
|
||
if (data.isZip) {
|
||
setZipPrefix(data.filename.replace(/\.[^/.]+$/, "").replace(/[^a-zA-Z0-9]/g, "_"));
|
||
setSelectedZipFiles(data.files.map((f: any) => f.name));
|
||
} else if (data.isSql) {
|
||
setSqlPrefix(data.filename.replace(/\.[^/.]+$/, "").replace(/[^a-zA-Z0-9]/g, "_"));
|
||
setSelectedSqlTables(data.tables?.slice(0, 10) || []);
|
||
} else if (data.isExcel || data.headers) {
|
||
setTableName(data.filename.replace(/\.[^/.]+$/, "").replace(/[^a-zA-Z0-9]/g, "_"));
|
||
const headers = data.headers || [];
|
||
console.log("[BI Upload] Excel/CSV headers:", headers);
|
||
if (headers.length === 0) {
|
||
console.warn("[BI Upload] No headers found in file");
|
||
}
|
||
setColumnMappings(
|
||
headers.map((h: string) => ({
|
||
originalName: h,
|
||
name: h.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(),
|
||
type: "text",
|
||
include: true,
|
||
}))
|
||
);
|
||
} else {
|
||
setTableName(data.filename.replace(/\.[^/.]+$/, "").replace(/[^a-zA-Z0-9]/g, "_"));
|
||
setColumnMappings([]);
|
||
}
|
||
},
|
||
});
|
||
|
||
const sqlImportMutation = useMutation({
|
||
mutationFn: async () => {
|
||
const res = await fetch("/api/bi/upload/sql-import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
filepath: uploadedFile.filepath,
|
||
selectedTables: selectedSqlTables,
|
||
targetPrefix: sqlPrefix,
|
||
}),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) {
|
||
const errData = await res.json();
|
||
throw new Error(errData.error || "SQL import failed");
|
||
}
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["datasets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["tables"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
setUploadedFile(null);
|
||
setSqlPrefix("");
|
||
setSelectedSqlTables([]);
|
||
},
|
||
});
|
||
|
||
const zipImportMutation = useMutation({
|
||
mutationFn: async () => {
|
||
const res = await fetch("/api/bi/upload/zip-import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
filepath: uploadedFile.filepath,
|
||
selectedFiles: selectedZipFiles,
|
||
targetPrefix: zipPrefix,
|
||
}),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("ZIP import failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["staged-tables"] });
|
||
queryClient.invalidateQueries({ queryKey: ["staging-stats"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
setUploadedFile(null);
|
||
setZipPrefix("");
|
||
setSelectedZipFiles([]);
|
||
},
|
||
});
|
||
|
||
const importMutation = useMutation({
|
||
mutationFn: async () => {
|
||
const res = await fetch("/api/bi/upload/import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
filepath: uploadedFile.filepath,
|
||
tableName,
|
||
headers: columnMappings.filter((c) => c.include),
|
||
fileType: uploadedFile.fileType,
|
||
}),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Import failed");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["datasets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
queryClient.invalidateQueries({ queryKey: ["tables"] });
|
||
setUploadedFile(null);
|
||
setColumnMappings([]);
|
||
setTableName("");
|
||
},
|
||
});
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(true);
|
||
};
|
||
|
||
const handleDragLeave = () => setIsDragging(false);
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
const files = e.dataTransfer.files;
|
||
if (files.length > 0) {
|
||
uploadMutation.mutate(files[0]);
|
||
}
|
||
};
|
||
|
||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
uploadMutation.mutate(e.target.files[0]);
|
||
}
|
||
};
|
||
|
||
const handleColumnDragStart = (index: number) => setDraggedColumn(index);
|
||
const handleColumnDragOver = (e: React.DragEvent, index: number) => {
|
||
e.preventDefault();
|
||
if (draggedColumn !== null && draggedColumn !== index) {
|
||
const newMappings = [...columnMappings];
|
||
const [removed] = newMappings.splice(draggedColumn, 1);
|
||
newMappings.splice(index, 0, removed);
|
||
setColumnMappings(newMappings);
|
||
setDraggedColumn(index);
|
||
}
|
||
};
|
||
const handleColumnDragEnd = () => setDraggedColumn(null);
|
||
|
||
const updateColumn = (index: number, field: string, value: any) => {
|
||
const newMappings = [...columnMappings];
|
||
newMappings[index] = { ...newMappings[index], [field]: value };
|
||
setColumnMappings(newMappings);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-lg font-medium text-white">Upload de Dados</h2>
|
||
</div>
|
||
|
||
{!uploadedFile ? (
|
||
<Card
|
||
className={`bg-[#1f334d] border-2 border-dashed transition-colors ${
|
||
isDragging ? "border-[#c89b3c] bg-[#c89b3c]/10" : "border-white/20"
|
||
}`}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
<CardContent className="p-12 text-center">
|
||
{uploadMutation.isPending ? (
|
||
<div className="flex flex-col items-center gap-4">
|
||
<Loader2 className="w-12 h-12 text-[#c89b3c] animate-spin" />
|
||
<p className="text-white/70">Processando arquivo...</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<FileSpreadsheet className="w-16 h-16 text-[#c89b3c]/50 mx-auto mb-4" />
|
||
<p className="text-white text-lg mb-2">Arraste e solte seu arquivo aqui</p>
|
||
<p className="text-white/50 mb-4">ou</p>
|
||
<label className="cursor-pointer">
|
||
<input
|
||
type="file"
|
||
accept=".csv,.txt,.json,.zip,.sql,.xlsx,.xls"
|
||
className="hidden"
|
||
onChange={handleFileInput}
|
||
/>
|
||
<Button className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]" asChild>
|
||
<span>
|
||
<Upload className="w-4 h-4 mr-2" /> Selecionar Arquivo
|
||
</span>
|
||
</Button>
|
||
</label>
|
||
<p className="text-white/40 text-sm mt-4">Formatos suportados: CSV, TXT, JSON, SQL, ZIP, Excel (.xlsx, .xls)</p>
|
||
<p className="text-white/30 text-xs mt-1">Arquivos ZIP podem conter backups SQL ou exportações MongoDB</p>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
) : uploadedFile.isZip ? (
|
||
<div className="space-y-6">
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<FileArchive className="w-5 h-5 text-[#c89b3c]" />
|
||
{uploadedFile.filename}
|
||
<Badge className="ml-2 bg-blue-500/20 text-blue-400">{uploadedFile.summary}</Badge>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Prefixo para Tabelas Staging</label>
|
||
<Input
|
||
value={zipPrefix}
|
||
onChange={(e) => setZipPrefix(e.target.value)}
|
||
className="bg-[#162638] border-white/20 text-white max-w-md"
|
||
placeholder="ex: migração_2024"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Layers className="w-5 h-5 text-[#c89b3c]" />
|
||
Conteúdo do ZIP
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{uploadedFile.files.map((file: any, index: number) => (
|
||
<div
|
||
key={index}
|
||
className="flex items-center gap-3 p-3 rounded-lg bg-[#162638] border border-white/10"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedZipFiles.includes(file.name)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedZipFiles([...selectedZipFiles, file.name]);
|
||
} else {
|
||
setSelectedZipFiles(selectedZipFiles.filter(f => f !== file.name));
|
||
}
|
||
}}
|
||
className="rounded border-white/30"
|
||
/>
|
||
{file.type === "sql" && <FileArchive className="w-4 h-4 text-blue-400" />}
|
||
{(file.type === "mongodb" || file.type === "bson") && <Database className="w-4 h-4 text-green-400" />}
|
||
{file.type === "csv" && <FileSpreadsheet className="w-4 h-4 text-purple-400" />}
|
||
{file.type === "unknown" && <FileText className="w-4 h-4 text-gray-400" />}
|
||
<div className="flex-1">
|
||
<p className="text-white">{file.name}</p>
|
||
<p className="text-white/50 text-sm">
|
||
{file.type === "sql" && file.tables?.length > 0 && `Tabelas: ${file.tables.join(", ")}`}
|
||
{file.type === "mongodb" && file.collections?.length > 0 && `Coleções: ${file.collections.map((c: any) => `${c.name} (${c.documents?.length || c.count || 0})`).join(", ")}`}
|
||
{file.type === "bson" && `${file.documentCount || 0} documentos MongoDB`}
|
||
{file.type === "csv" && `Tipo: CSV/TXT`}
|
||
{file.type === "unknown" && `Tipo não suportado`}
|
||
</p>
|
||
</div>
|
||
<Badge className={
|
||
file.type === "sql" ? "bg-blue-500/20 text-blue-400" :
|
||
(file.type === "mongodb" || file.type === "bson") ? "bg-green-500/20 text-green-400" :
|
||
file.type === "csv" ? "bg-purple-500/20 text-purple-400" :
|
||
"bg-gray-500/20 text-gray-400"
|
||
}>
|
||
{file.type === "bson" ? "MONGODB/BSON" : file.type.toUpperCase()}
|
||
</Badge>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex gap-3">
|
||
<Button
|
||
onClick={() => {
|
||
setUploadedFile(null);
|
||
setZipPrefix("");
|
||
setSelectedZipFiles([]);
|
||
}}
|
||
variant="outline"
|
||
className="text-white/70 border-white/20"
|
||
>
|
||
Cancelar
|
||
</Button>
|
||
<Button
|
||
onClick={() => zipImportMutation.mutate()}
|
||
disabled={selectedZipFiles.length === 0 || !zipPrefix || zipImportMutation.isPending}
|
||
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]"
|
||
>
|
||
{zipImportMutation.isPending ? (
|
||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||
) : (
|
||
<ArrowRight className="w-4 h-4 mr-2" />
|
||
)}
|
||
Importar para Staging ({selectedZipFiles.length} arquivos)
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : uploadedFile.isSql ? (
|
||
<div className="space-y-6">
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/30 border-2">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Database className="w-5 h-5 text-[#c89b3c]" />
|
||
{uploadedFile.filename}
|
||
<Badge className="ml-2 bg-blue-500/20 text-blue-400">{uploadedFile.tableCount} tabelas</Badge>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Prefixo para Tabelas</label>
|
||
<Input
|
||
value={sqlPrefix}
|
||
onChange={(e) => setSqlPrefix(e.target.value)}
|
||
className="bg-[#162638] border-white/20 text-white max-w-md"
|
||
placeholder="ex: erp_backup"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-2 block">
|
||
Selecione as tabelas para importar ({selectedSqlTables.length} de {uploadedFile.tables?.length || 0})
|
||
</label>
|
||
<div className="flex gap-2 mb-3">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setSelectedSqlTables(uploadedFile.tables || [])}
|
||
className="text-white/70 border-white/20"
|
||
>
|
||
Selecionar Todas
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setSelectedSqlTables([])}
|
||
className="text-white/70 border-white/20"
|
||
>
|
||
Limpar Seleção
|
||
</Button>
|
||
</div>
|
||
<ScrollArea className="h-64 border border-white/10 rounded-lg p-2">
|
||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||
{(uploadedFile.tables || []).map((table: string) => (
|
||
<div
|
||
key={table}
|
||
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
||
selectedSqlTables.includes(table)
|
||
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
|
||
: "bg-[#162638] border border-white/10 hover:border-white/30"
|
||
}`}
|
||
onClick={() => {
|
||
if (selectedSqlTables.includes(table)) {
|
||
setSelectedSqlTables(selectedSqlTables.filter(t => t !== table));
|
||
} else {
|
||
setSelectedSqlTables([...selectedSqlTables, table]);
|
||
}
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedSqlTables.includes(table)}
|
||
onChange={() => {}}
|
||
className="rounded border-white/30"
|
||
/>
|
||
<span className="text-white/80 text-sm truncate">{table}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex gap-3">
|
||
<Button
|
||
onClick={() => {
|
||
setUploadedFile(null);
|
||
setSqlPrefix("");
|
||
setSelectedSqlTables([]);
|
||
}}
|
||
variant="outline"
|
||
className="text-white/70 border-white/20"
|
||
>
|
||
Cancelar
|
||
</Button>
|
||
<Button
|
||
onClick={() => sqlImportMutation.mutate()}
|
||
disabled={selectedSqlTables.length === 0 || !sqlPrefix || sqlImportMutation.isPending}
|
||
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d]"
|
||
>
|
||
{sqlImportMutation.isPending ? (
|
||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||
) : (
|
||
<Database className="w-4 h-4 mr-2" />
|
||
)}
|
||
Importar {selectedSqlTables.length} Tabelas
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/30 border-2">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<FileSpreadsheet className="w-5 h-5 text-[#c89b3c]" />
|
||
{uploadedFile.filename}
|
||
<Badge className="ml-2 bg-green-500/20 text-green-400">{uploadedFile.rowCount} registros</Badge>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<Button
|
||
onClick={() => {
|
||
setUploadedFile(null);
|
||
setColumnMappings([]);
|
||
setTableName("");
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-white/70 border-white/20"
|
||
>
|
||
Cancelar
|
||
</Button>
|
||
<Button
|
||
onClick={() => importMutation.mutate()}
|
||
disabled={!tableName || importMutation.isPending}
|
||
size="sm"
|
||
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d] font-semibold px-6"
|
||
data-testid="button-import-data"
|
||
>
|
||
{importMutation.isPending ? (
|
||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||
) : (
|
||
<Save className="w-4 h-4 mr-2" />
|
||
)}
|
||
Salvar e Importar
|
||
</Button>
|
||
</div>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div>
|
||
<label className="text-sm text-white/70 mb-1 block">Nome da Tabela</label>
|
||
<Input
|
||
value={tableName}
|
||
onChange={(e) => setTableName(e.target.value)}
|
||
className="bg-[#162638] border-white/20 text-white max-w-md"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Columns className="w-5 h-5 text-[#c89b3c]" />
|
||
Mapeamento de Colunas
|
||
<span className="text-sm font-normal text-white/50 ml-2">(arraste para reordenar)</span>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{columnMappings.map((col, index) => (
|
||
<div
|
||
key={index}
|
||
draggable
|
||
onDragStart={() => handleColumnDragStart(index)}
|
||
onDragOver={(e) => handleColumnDragOver(e, index)}
|
||
onDragEnd={handleColumnDragEnd}
|
||
className={`flex items-center gap-3 p-3 rounded-lg bg-[#162638] border ${
|
||
draggedColumn === index ? "border-[#c89b3c]" : "border-white/10"
|
||
} cursor-grab active:cursor-grabbing`}
|
||
>
|
||
<GripVertical className="w-4 h-4 text-white/40" />
|
||
<input
|
||
type="checkbox"
|
||
checked={col.include}
|
||
onChange={(e) => updateColumn(index, "include", e.target.checked)}
|
||
className="rounded border-white/30"
|
||
/>
|
||
<div className="flex-1 grid grid-cols-3 gap-3">
|
||
<div>
|
||
<span className="text-xs text-white/40 block mb-1">Original</span>
|
||
<span className="text-white/70">{col.originalName}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-white/40 block mb-1">Nome</span>
|
||
<Input
|
||
value={col.name}
|
||
onChange={(e) => updateColumn(index, "name", e.target.value)}
|
||
className="h-8 bg-[#1f334d] border-white/20 text-white text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<span className="text-xs text-white/40 block mb-1">Tipo</span>
|
||
<Select value={col.type} onValueChange={(v) => updateColumn(index, "type", v)}>
|
||
<SelectTrigger className="h-8 bg-[#1f334d] border-white/20 text-white text-sm">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[#162638] border-white/20">
|
||
<SelectItem value="text" className="text-white">Texto</SelectItem>
|
||
<SelectItem value="integer" className="text-white">Inteiro</SelectItem>
|
||
<SelectItem value="decimal" className="text-white">Decimal</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#1f334d] border-[#c89b3c]/20">
|
||
<CardHeader>
|
||
<CardTitle className="text-white flex items-center gap-2">
|
||
<Table className="w-5 h-5 text-[#c89b3c]" />
|
||
Prévia dos Dados
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ScrollArea className="w-full">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-white/10">
|
||
{columnMappings
|
||
.filter((c) => c.include)
|
||
.map((col, i) => (
|
||
<th key={i} className="text-left py-2 px-3 text-white/70 font-medium">
|
||
{col.name}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(uploadedFile.preview || []).slice(0, 5).map((row: any, i: number) => (
|
||
<tr key={i} className="border-b border-white/5">
|
||
{columnMappings
|
||
.filter((c) => c.include)
|
||
.map((col, j) => (
|
||
<td key={j} className="py-2 px-3 text-white/60">
|
||
{row[col.originalName] || "-"}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StagingTab() {
|
||
const queryClient = useQueryClient();
|
||
const { data: stagedTables = [], isLoading } = useQuery({ queryKey: ["staged-tables"], queryFn: fetchStagedTables });
|
||
const { data: stats } = useQuery({ queryKey: ["staging-stats"], queryFn: fetchStagingStats });
|
||
const { data: erpTargets = [] } = useQuery({ queryKey: ["erp-targets"], queryFn: fetchErpTargets });
|
||
const [selectedTable, setSelectedTable] = useState<StagedTable | null>(null);
|
||
const [viewingData, setViewingData] = useState(false);
|
||
const [tableData, setTableData] = useState<any[]>([]);
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: async (id: number) => {
|
||
const res = await fetch(`/api/staging/tables/${id}`, {
|
||
method: "DELETE",
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Failed to delete");
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["staged-tables"] });
|
||
queryClient.invalidateQueries({ queryKey: ["staging-stats"] });
|
||
},
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: async ({ id, data }: { id: number; data: any }) => {
|
||
const res = await fetch(`/api/staging/tables/${id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(data),
|
||
});
|
||
if (!res.ok) throw new Error("Failed to update");
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["staged-tables"] });
|
||
},
|
||
});
|
||
|
||
const viewData = async (table: StagedTable) => {
|
||
try {
|
||
const res = await fetch(`/api/staging/tables/${table.id}/data?limit=50`, { credentials: "include" });
|
||
if (res.ok) {
|
||
const result = await res.json();
|
||
setTableData(result.data);
|
||
setSelectedTable(table);
|
||
setViewingData(true);
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
const getSourceIcon = (type: string) => {
|
||
switch (type) {
|
||
case "sql": return <FileArchive className="w-4 h-4 text-blue-400" />;
|
||
case "mongodb": return <Database className="w-4 h-4 text-green-400" />;
|
||
case "csv": return <FileSpreadsheet className="w-4 h-4 text-purple-400" />;
|
||
case "json": return <FileText className="w-4 h-4 text-yellow-400" />;
|
||
default: return <Layers className="w-4 h-4 text-gray-400" />;
|
||
}
|
||
};
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
const colors: Record<string, string> = {
|
||
ready: "bg-blue-500/20 text-blue-400",
|
||
mapped: "bg-purple-500/20 text-purple-400",
|
||
migrating: "bg-yellow-500/20 text-yellow-400",
|
||
migrated: "bg-green-500/20 text-green-400",
|
||
error: "bg-red-500/20 text-red-400",
|
||
};
|
||
const labels: Record<string, string> = {
|
||
ready: "Pronto",
|
||
mapped: "Mapeado",
|
||
migrating: "Migrando",
|
||
migrated: "Migrado",
|
||
error: "Erro",
|
||
};
|
||
return <Badge className={colors[status] || "bg-gray-500/20 text-gray-400"}>{labels[status] || status}</Badge>;
|
||
};
|
||
|
||
if (isLoading) {
|
||
return <div className="flex justify-center py-12"><Loader2 className="w-8 h-8 animate-spin text-[#c89b3c]" /></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-4 gap-4">
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-blue-500/20">
|
||
<Layers className="w-5 h-5 text-blue-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.totalTables || 0}</p>
|
||
<p className="text-xs text-white/50">Tabelas Staging</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-purple-500/20">
|
||
<Table className="w-5 h-5 text-purple-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.totalRecords?.toLocaleString() || 0}</p>
|
||
<p className="text-xs text-white/50">Total de Registros</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-green-500/20">
|
||
<MapPin className="w-5 h-5 text-green-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.mappedTables || 0}</p>
|
||
<p className="text-xs text-white/50">Mapeados</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className="p-3 rounded-full bg-[#c89b3c]/20">
|
||
<Send className="w-5 h-5 text-[#c89b3c]" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-white">{stats?.completedMigrations || 0}</p>
|
||
<p className="text-xs text-white/50">Migrações Completas</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||
<Layers className="w-5 h-5 text-[#c89b3c]" />
|
||
Tabelas em Staging
|
||
</CardTitle>
|
||
<p className="text-white/50 text-sm">
|
||
Faça upload de arquivos ZIP na aba Upload para importar dados
|
||
</p>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{stagedTables.length === 0 ? (
|
||
<div className="text-center py-12 text-white/50">
|
||
<Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||
<p>Nenhuma tabela em staging</p>
|
||
<p className="text-sm mt-1">Faça upload de arquivos ZIP/SQL/JSON para começar</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{stagedTables.map((table) => (
|
||
<div
|
||
key={table.id}
|
||
className="flex items-center justify-between p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
{getSourceIcon(table.sourceType)}
|
||
<div>
|
||
<p className="text-white font-medium">{table.name}</p>
|
||
<p className="text-white/50 text-sm">
|
||
{table.tableName} • {table.rowCount.toLocaleString()} registros
|
||
{table.sourceFile && ` • ${table.sourceFile}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{getStatusBadge(table.status)}
|
||
{table.targetErp && (
|
||
<Badge className="bg-[#c89b3c]/20 text-[#c89b3c]">
|
||
{erpTargets.find(e => e.id === table.targetErp)?.name || table.targetErp}
|
||
</Badge>
|
||
)}
|
||
<div className="flex gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => viewData(table)}
|
||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</Button>
|
||
<Select
|
||
value={table.targetErp || ""}
|
||
onValueChange={(value) => updateMutation.mutate({ id: table.id, data: { targetErp: value } })}
|
||
>
|
||
<SelectTrigger className="w-32 h-8 bg-white/5 border-white/10 text-white text-xs">
|
||
<SelectValue placeholder="Destino ERP" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{erpTargets.map((erp) => (
|
||
<SelectItem key={erp.id} value={erp.id}>{erp.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => deleteMutation.mutate(table.id)}
|
||
className="text-red-400/60 hover:text-red-400 hover:bg-red-500/10"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog open={viewingData} onOpenChange={setViewingData}>
|
||
<DialogContent className="max-w-4xl bg-[#1f334d] border-[#c89b3c]/20">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white flex items-center gap-2">
|
||
<Table className="w-5 h-5 text-[#c89b3c]" />
|
||
{selectedTable?.name} - Dados
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<ScrollArea className="h-[400px]">
|
||
{tableData.length > 0 && (
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-white/10">
|
||
{Object.keys(tableData[0]).filter(k => k !== "id").map((key) => (
|
||
<th key={key} className="text-left py-2 px-3 text-white/70 font-medium">{key}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{tableData.slice(0, 20).map((row, i) => (
|
||
<tr key={i} className="border-b border-white/5">
|
||
{Object.entries(row).filter(([k]) => k !== "id").map(([k, v], j) => (
|
||
<td key={j} className="py-2 px-3 text-white/60 max-w-[200px] truncate">
|
||
{String(v || "-")}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</ScrollArea>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Card className="bg-[#1f334d] border-none">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||
<Database className="w-5 h-5 text-[#c89b3c]" />
|
||
Sistemas ERP Suportados
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{erpTargets.map((erp) => (
|
||
<div key={erp.id} className="p-4 rounded-lg bg-white/5">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Database className="w-4 h-4 text-[#c89b3c]" />
|
||
<span className="text-white font-medium">{erp.name}</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{erp.entities.map((entity) => (
|
||
<Badge key={entity} variant="outline" className="text-white/50 border-white/20 text-xs">
|
||
{entity}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface BiMessage {
|
||
role: 'user' | 'assistant';
|
||
content: string;
|
||
}
|
||
|
||
interface CoPilotDataset {
|
||
id: number;
|
||
name: string;
|
||
tableName?: string | null;
|
||
description?: string | null;
|
||
}
|
||
|
||
function BiCoPilot({ isOpen, onClose, datasets }: { isOpen: boolean; onClose: () => void; datasets: CoPilotDataset[] }) {
|
||
const [messages, setMessages] = useState<BiMessage[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [selectedDataset, setSelectedDataset] = useState<CoPilotDataset | null>(null);
|
||
const [activeTab, setActiveTab] = useState<'chat' | 'actions'>('actions');
|
||
const queryClient = useQueryClient();
|
||
|
||
const runAction = async (actionPrompt: string) => {
|
||
setActiveTab('chat');
|
||
setMessages(prev => [...prev, { role: 'user', content: actionPrompt }]);
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
const contextInfo = selectedDataset
|
||
? `[CONTEXTO BI AUTOMÁTICO - USE ESTES DADOS DIRETAMENTE, NÃO PEÇA CONFIGURAÇÃO:
|
||
Dataset selecionado: "${selectedDataset.name}" (ID: ${selectedDataset.id})
|
||
Tabela: ${selectedDataset.tableName || 'N/A'}
|
||
IMPORTANTE: Execute a análise diretamente usando bi_execute_query ou bi_get_dataset_data. NÃO peça ao usuário para configurar fontes de dados.]
|
||
|
||
`
|
||
: `[CONTEXTO BI: ${datasets.length} datasets disponíveis. Use bi_list_datasets para ver todos. NÃO peça configuração manual.]
|
||
|
||
`;
|
||
|
||
const res = await fetch("/api/manus/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ prompt: contextInfo + actionPrompt }),
|
||
credentials: "include",
|
||
});
|
||
|
||
if (!res.ok) throw new Error("Falha na requisição");
|
||
const { runId } = await res.json();
|
||
|
||
let attempts = 0;
|
||
const maxAttempts = 60;
|
||
while (attempts < maxAttempts) {
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
const statusRes = await fetch(`/api/manus/runs/${runId}`, { credentials: "include" });
|
||
const run = await statusRes.json();
|
||
|
||
if (run.status === 'completed' || run.status === 'stopped') {
|
||
const lastStep = run.steps?.[run.steps.length - 1];
|
||
const response = lastStep?.toolOutput || run.result || "Análise concluída.";
|
||
setMessages(prev => [...prev, { role: 'assistant', content: response }]);
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-datasets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-charts"] });
|
||
break;
|
||
}
|
||
attempts++;
|
||
}
|
||
} catch (error) {
|
||
setMessages(prev => [...prev, { role: 'assistant', content: "Erro ao processar. Tente novamente." }]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const sendMessage = async () => {
|
||
if (!input.trim() || isLoading) return;
|
||
const userMessage = input.trim();
|
||
setInput("");
|
||
runAction(userMessage);
|
||
};
|
||
|
||
const quickActions = [
|
||
{ icon: "📊", label: "Resumo Executivo", prompt: "Faça um resumo executivo completo dos dados, incluindo principais métricas e insights" },
|
||
{ icon: "🔍", label: "Detectar Padrões", prompt: "Analise os dados e detecte padrões, tendências e anomalias importantes" },
|
||
{ icon: "📈", label: "Criar Gráfico", prompt: "Crie um gráfico apropriado para visualizar os principais dados" },
|
||
{ icon: "📋", label: "Estatísticas", prompt: "Calcule estatísticas descritivas: média, mediana, desvio padrão, mínimo e máximo" },
|
||
{ icon: "💡", label: "Insights de Negócio", prompt: "Gere insights de negócio acionáveis baseados na análise dos dados" },
|
||
{ icon: "📉", label: "Análise de Tendência", prompt: "Faça uma análise de tendência temporal dos dados, se aplicável" },
|
||
];
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="fixed bottom-4 right-4 w-[480px] h-[650px] bg-[#1f334d] rounded-xl shadow-2xl border border-[#c89b3c]/30 flex flex-col z-50">
|
||
<div className="flex items-center justify-between p-4 border-b border-[#c89b3c]/20">
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-2 rounded-lg bg-gradient-to-br from-[#c89b3c] to-[#a07830]">
|
||
<Sparkles className="w-5 h-5 text-white" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-white font-medium">Co-Piloto BI</h3>
|
||
<p className="text-white/50 text-xs">Análise inteligente integrada</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||
className="p-2 text-white/50 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||
data-testid="button-close-bi-copilot"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Dataset Selector */}
|
||
<div className="p-3 border-b border-[#c89b3c]/20 bg-white/5">
|
||
<label className="text-white/50 text-xs block mb-1">Dataset para análise:</label>
|
||
<select
|
||
value={selectedDataset?.id || ""}
|
||
onChange={(e) => {
|
||
const ds = datasets.find(d => d.id === Number(e.target.value));
|
||
setSelectedDataset(ds || null);
|
||
}}
|
||
className="w-full bg-[#1f334d] border border-white/20 rounded-lg px-3 py-2 text-white text-sm focus:border-[#c89b3c] focus:outline-none"
|
||
>
|
||
<option value="">Todos os datasets ({datasets.length})</option>
|
||
{datasets.map(ds => (
|
||
<option key={ds.id} value={ds.id}>{ds.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Tab Switcher */}
|
||
<div className="flex border-b border-[#c89b3c]/20">
|
||
<button
|
||
onClick={() => setActiveTab('actions')}
|
||
className={`flex-1 py-2 text-sm font-medium transition-colors ${activeTab === 'actions' ? 'text-[#c89b3c] border-b-2 border-[#c89b3c]' : 'text-white/50 hover:text-white'}`}
|
||
>
|
||
⚡ Ações Rápidas
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('chat')}
|
||
className={`flex-1 py-2 text-sm font-medium transition-colors ${activeTab === 'chat' ? 'text-[#c89b3c] border-b-2 border-[#c89b3c]' : 'text-white/50 hover:text-white'}`}
|
||
>
|
||
💬 Chat
|
||
</button>
|
||
</div>
|
||
|
||
<ScrollArea className="flex-1 p-4">
|
||
{activeTab === 'actions' ? (
|
||
<div className="space-y-3">
|
||
<p className="text-white/60 text-xs mb-3">
|
||
{selectedDataset
|
||
? `Clique para analisar: "${selectedDataset.name}"`
|
||
: "Selecione um dataset acima ou clique para análise geral"}
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{quickActions.map((action, i) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => runAction(action.prompt)}
|
||
disabled={isLoading}
|
||
className="p-3 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 hover:border-[#c89b3c]/50 transition-all text-left disabled:opacity-50"
|
||
>
|
||
<span className="text-xl block mb-1">{action.icon}</span>
|
||
<span className="text-white text-sm font-medium">{action.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{isLoading && (
|
||
<div className="flex items-center justify-center gap-2 py-4 mt-4 bg-white/5 rounded-lg">
|
||
<Loader2 className="w-5 h-5 text-[#c89b3c] animate-spin" />
|
||
<span className="text-white/70 text-sm">Processando análise...</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{messages.length === 0 && (
|
||
<div className="text-center py-6">
|
||
<Bot className="w-10 h-10 text-[#c89b3c]/50 mx-auto mb-2" />
|
||
<p className="text-white/60 text-sm">Envie uma pergunta ou use as Ações Rápidas</p>
|
||
</div>
|
||
)}
|
||
{messages.map((msg, i) => (
|
||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||
<div className={`max-w-[90%] p-3 rounded-lg text-sm ${
|
||
msg.role === 'user'
|
||
? 'bg-[#c89b3c] text-[#1f334d]'
|
||
: 'bg-white/10 text-white'
|
||
}`}>
|
||
<pre className="whitespace-pre-wrap font-sans text-xs">{msg.content}</pre>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{isLoading && (
|
||
<div className="flex justify-start">
|
||
<div className="bg-white/10 p-3 rounded-lg flex items-center gap-2">
|
||
<Loader2 className="w-4 h-4 text-[#c89b3c] animate-spin" />
|
||
<span className="text-white/70 text-sm">Analisando...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</ScrollArea>
|
||
|
||
<div className="p-3 border-t border-[#c89b3c]/20">
|
||
<div className="flex gap-2">
|
||
<Textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder="Pergunte sobre seus dados..."
|
||
className="flex-1 bg-white/5 border-white/20 text-white placeholder:text-white/40 resize-none text-sm"
|
||
rows={2}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage();
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
onClick={sendMessage}
|
||
disabled={!input.trim() || isLoading}
|
||
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d] self-end"
|
||
>
|
||
<Send className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BiAssistant({ isOpen, onClose, context }: { isOpen: boolean; onClose: () => void; context: string }) {
|
||
const [messages, setMessages] = useState<BiMessage[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const queryClient = useQueryClient();
|
||
|
||
const sendMessage = async () => {
|
||
if (!input.trim() || isLoading) return;
|
||
|
||
const userMessage = input.trim();
|
||
setInput("");
|
||
setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
const contextPrompt = context
|
||
? `[Contexto BI: ${context}]\n\n${userMessage}`
|
||
: userMessage;
|
||
|
||
const res = await fetch("/api/manus/start", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ prompt: contextPrompt }),
|
||
credentials: "include",
|
||
});
|
||
|
||
if (!res.ok) throw new Error("Falha na requisição");
|
||
const { runId } = await res.json();
|
||
|
||
let attempts = 0;
|
||
const maxAttempts = 30;
|
||
while (attempts < maxAttempts) {
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
const statusRes = await fetch(`/api/manus/runs/${runId}`, { credentials: "include" });
|
||
const run = await statusRes.json();
|
||
|
||
if (run.status === 'completed' || run.status === 'stopped') {
|
||
const lastStep = run.steps?.[run.steps.length - 1];
|
||
const response = lastStep?.toolOutput || run.result || "Análise concluída.";
|
||
setMessages(prev => [...prev, { role: 'assistant', content: response }]);
|
||
queryClient.invalidateQueries({ queryKey: ["bi-stats"] });
|
||
queryClient.invalidateQueries({ queryKey: ["data-sources"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-datasets"] });
|
||
queryClient.invalidateQueries({ queryKey: ["bi-charts"] });
|
||
break;
|
||
}
|
||
attempts++;
|
||
}
|
||
} catch (error) {
|
||
setMessages(prev => [...prev, { role: 'assistant', content: "Erro ao processar solicitação. Tente novamente." }]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="fixed bottom-4 right-4 w-[420px] h-[600px] bg-[#1f334d] rounded-xl shadow-2xl border border-[#c89b3c]/30 flex flex-col z-50">
|
||
<div className="flex items-center justify-between p-4 border-b border-[#c89b3c]/20">
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-2 rounded-lg bg-[#c89b3c]/20">
|
||
<Sparkles className="w-5 h-5 text-[#c89b3c]" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-white font-medium">Cientista de Dados IA</h3>
|
||
<p className="text-white/50 text-xs">Análise inteligente de dados</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||
className="p-2 text-white/50 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||
data-testid="button-close-bi-assistant"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<ScrollArea className="flex-1 p-4">
|
||
<div className="space-y-4">
|
||
{messages.length === 0 && (
|
||
<div className="text-center py-8">
|
||
<Bot className="w-12 h-12 text-[#c89b3c]/50 mx-auto mb-3" />
|
||
<p className="text-white/70 text-sm mb-2">Olá! Sou seu Cientista de Dados IA.</p>
|
||
<p className="text-white/50 text-xs mb-4">Posso ajudar a analisar dados, criar gráficos e descobrir insights.</p>
|
||
<div className="space-y-2">
|
||
<button
|
||
onClick={() => setInput("Mostrar estatísticas do BI")}
|
||
className="w-full text-left text-xs p-2 rounded bg-white/5 text-white/70 hover:bg-white/10"
|
||
>
|
||
📊 Mostrar estatísticas do BI
|
||
</button>
|
||
<button
|
||
onClick={() => setInput("Listar tabelas disponíveis no banco")}
|
||
className="w-full text-left text-xs p-2 rounded bg-white/5 text-white/70 hover:bg-white/10"
|
||
>
|
||
📋 Listar tabelas do banco de dados
|
||
</button>
|
||
<button
|
||
onClick={() => setInput("Criar um dataset com dados de parceiros")}
|
||
className="w-full text-left text-xs p-2 rounded bg-white/5 text-white/70 hover:bg-white/10"
|
||
>
|
||
➕ Criar dataset de parceiros
|
||
</button>
|
||
<button
|
||
onClick={() => setInput("Detectar padrões nos dados de contratos")}
|
||
className="w-full text-left text-xs p-2 rounded bg-white/5 text-white/70 hover:bg-white/10"
|
||
>
|
||
🔍 Detectar padrões nos dados
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{messages.map((msg, i) => (
|
||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||
<div className={`max-w-[85%] p-3 rounded-lg text-sm ${
|
||
msg.role === 'user'
|
||
? 'bg-[#c89b3c] text-[#1f334d]'
|
||
: 'bg-white/10 text-white'
|
||
}`}>
|
||
<pre className="whitespace-pre-wrap font-sans">{msg.content}</pre>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{isLoading && (
|
||
<div className="flex justify-start">
|
||
<div className="bg-white/10 p-3 rounded-lg flex items-center gap-2">
|
||
<Loader2 className="w-4 h-4 text-[#c89b3c] animate-spin" />
|
||
<span className="text-white/70 text-sm">Analisando...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<div className="p-4 border-t border-[#c89b3c]/20">
|
||
<div className="flex gap-2">
|
||
<Textarea
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
placeholder="Pergunte sobre seus dados..."
|
||
className="flex-1 bg-white/5 border-white/20 text-white placeholder:text-white/40 resize-none"
|
||
rows={2}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage();
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
onClick={sendMessage}
|
||
disabled={!input.trim() || isLoading}
|
||
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d] self-end"
|
||
>
|
||
<Send className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function BiWorkspace() {
|
||
const [activeTab, setActiveTab] = useState("overview");
|
||
const [assistantOpen, setAssistantOpen] = useState(false);
|
||
|
||
const { data: stats } = useQuery({ queryKey: ["bi-stats"], queryFn: fetchStats });
|
||
const { data: dataSources = [] } = useQuery({ queryKey: ["data-sources"], queryFn: fetchDataSources });
|
||
const { data: datasets = [] } = useQuery({ queryKey: ["bi-datasets"], queryFn: fetchDatasets });
|
||
|
||
const buildContext = () => {
|
||
const parts = [];
|
||
parts.push(`Aba ativa: ${activeTab}`);
|
||
if (stats) {
|
||
parts.push(`Estatísticas: ${stats.dataSources} fontes de dados, ${stats.datasets} datasets, ${stats.charts} gráficos, ${stats.dashboards} dashboards`);
|
||
}
|
||
if (dataSources.length > 0) {
|
||
parts.push(`Fontes configuradas: ${dataSources.map((d: DataSource) => d.name).join(', ')}`);
|
||
}
|
||
if (datasets.length > 0) {
|
||
parts.push(`Datasets disponíveis: ${datasets.map((d: Dataset) => `${d.name} (ID:${d.id})`).join(', ')}`);
|
||
}
|
||
return parts.join('. ');
|
||
};
|
||
|
||
return (
|
||
<BrowserFrame>
|
||
<div className="min-h-full bg-gradient-to-br from-[#f5f7fa] to-[#e8ecf1]">
|
||
<div className="bg-[#1f334d] border-b border-[#c89b3c]/20 px-6 py-4">
|
||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-light text-white" style={{ fontFamily: 'Georgia, serif' }}>
|
||
<span className="italic text-[#c89b3c]">Arcádia</span> Insights
|
||
</h1>
|
||
<p className="text-white/50 text-sm">Plataforma de Business Intelligence</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Button variant="outline" size="sm" className="text-white/70 border-white/20 hover:bg-white/5">
|
||
<RefreshCw className="w-4 h-4 mr-2" /> Atualizar
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-7xl mx-auto p-6">
|
||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||
<TabsList className="bg-[#1f334d] border border-[#c89b3c]/20 p-1">
|
||
<TabsTrigger value="overview" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<LayoutDashboard className="w-4 h-4 mr-2" /> Visão Geral
|
||
</TabsTrigger>
|
||
<TabsTrigger value="datasources" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<Database className="w-4 h-4 mr-2" /> Fontes de Dados
|
||
</TabsTrigger>
|
||
<TabsTrigger value="upload" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<Upload className="w-4 h-4 mr-2" /> Upload
|
||
</TabsTrigger>
|
||
<TabsTrigger value="datasets" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<FileText className="w-4 h-4 mr-2" /> Consultas
|
||
</TabsTrigger>
|
||
<TabsTrigger value="charts" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<BarChart3 className="w-4 h-4 mr-2" /> Gráficos
|
||
</TabsTrigger>
|
||
<TabsTrigger value="backups" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<Archive className="w-4 h-4 mr-2" /> Backups
|
||
</TabsTrigger>
|
||
<TabsTrigger value="staging" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<Layers className="w-4 h-4 mr-2" /> Staging
|
||
</TabsTrigger>
|
||
<TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||
<Settings className="w-4 h-4 mr-2" /> Insights
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="overview" className="mt-0">
|
||
<OverviewTab />
|
||
</TabsContent>
|
||
<TabsContent value="datasources" className="mt-0">
|
||
<DataSourcesTab />
|
||
</TabsContent>
|
||
<TabsContent value="upload" className="mt-0">
|
||
<UploadTab />
|
||
</TabsContent>
|
||
<TabsContent value="datasets" className="mt-0">
|
||
<DatasetsTab />
|
||
</TabsContent>
|
||
<TabsContent value="charts" className="mt-0">
|
||
<ChartsTab />
|
||
</TabsContent>
|
||
<TabsContent value="backups" className="mt-0">
|
||
<BackupsTab />
|
||
</TabsContent>
|
||
<TabsContent value="staging" className="mt-0">
|
||
<StagingTab />
|
||
</TabsContent>
|
||
<TabsContent value="advanced" className="mt-0">
|
||
<SupersetAdvancedTab />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Floating AI Co-Pilot Button */}
|
||
{!assistantOpen && (
|
||
<button
|
||
onClick={() => setAssistantOpen(true)}
|
||
className="fixed bottom-6 right-6 w-16 h-16 bg-gradient-to-br from-[#c89b3c] to-[#a07830] hover:from-[#d4a94a] hover:to-[#b08840] rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 z-40"
|
||
data-testid="button-open-bi-copilot"
|
||
>
|
||
<Sparkles className="w-7 h-7 text-white" />
|
||
</button>
|
||
)}
|
||
|
||
{/* AI Co-Pilot Panel */}
|
||
<BiCoPilot
|
||
isOpen={assistantOpen}
|
||
onClose={() => setAssistantOpen(false)}
|
||
datasets={datasets.map((d: Dataset) => ({ id: d.id, name: d.name, tableName: d.tableName, description: d.description }))}
|
||
/>
|
||
</BrowserFrame>
|
||
);
|
||
}
|