diff --git a/src/components/mika/integrations/DisconnectMCPDialog.tsx b/src/components/mika/integrations/DisconnectMCPDialog.tsx
new file mode 100644
index 0000000..8b94891
--- /dev/null
+++ b/src/components/mika/integrations/DisconnectMCPDialog.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { AlertTriangle } from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useDependentJobsForMcp } from "@/hooks/use-integrations";
+import { invokeFunction } from "@/lib/invoke-function";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ integrationId: string;
+ mcpSlug: string;
+ mcpName: string;
+}
+
+export function DisconnectMCPDialog({
+ open,
+ onOpenChange,
+ integrationId,
+ mcpSlug,
+ mcpName,
+}: Props) {
+ const { data: dependentJobs = [], isLoading } = useDependentJobsForMcp(
+ open ? mcpSlug : undefined,
+ );
+ const queryClient = useQueryClient();
+ const [submitting, setSubmitting] = useState(false);
+
+ const activeJobs = dependentJobs.filter((j) => j.status === "active");
+ const hasActiveJobs = activeJobs.length > 0;
+
+ async function handleDisconnect() {
+ setSubmitting(true);
+ const { error } = await invokeFunction("disconnect-integration", {
+ integration_id: integrationId,
+ });
+ setSubmitting(false);
+ if (error) {
+ toast.error(error.message);
+ return;
+ }
+ toast.success(`${mcpName} desconectado.`);
+ queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
+ queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] });
+ onOpenChange(false);
+ }
+
+ return (
+
+
+
+ Desconectar {mcpName}?
+
+
+
+ A conexão será removida e os tokens serão revogados. Você pode reconectar
+ a qualquer momento.
+
+
+ {isLoading && (
+
+ Verificando automações dependentes...
+
+ )}
+
+ {!isLoading && hasActiveJobs && (
+
+
+
+
+
+ {activeJobs.length}{" "}
+ {activeJobs.length === 1 ? "automação ativa" : "automações ativas"}{" "}
+ depende{activeJobs.length === 1 ? "" : "m"} desta integração:
+
+
+ {activeJobs.slice(0, 5).map((j) => (
+ - {j.name}
+ ))}
+ {activeJobs.length > 5 && (
+ - e mais {activeJobs.length - 5}...
+ )}
+
+
+ Pause ou exclua essas automações antes de desconectar.
+
+
+
+
+ )}
+
+
+
+
+ Cancelar
+ {
+ e.preventDefault();
+ handleDisconnect();
+ }}
+ disabled={submitting || isLoading || hasActiveJobs}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {submitting ? "Desconectando..." : "Desconectar"}
+
+
+
+
+ );
+}
diff --git a/src/components/mika/integrations/IntegrationCard.tsx b/src/components/mika/integrations/IntegrationCard.tsx
new file mode 100644
index 0000000..94be0d0
--- /dev/null
+++ b/src/components/mika/integrations/IntegrationCard.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { Link } from "@tanstack/react-router";
+import { Lock, AlertCircle, CheckCircle2, Plug } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import type { IntegrationCardState } from "@/hooks/use-integrations";
+import { toast } from "sonner";
+import { useState } from "react";
+import { invokeFunction } from "@/lib/invoke-function";
+
+interface Props {
+ state: IntegrationCardState;
+ agentReady: boolean;
+}
+
+export function IntegrationCard({ state, agentReady }: Props) {
+ const { mcp } = state;
+ const [connecting, setConnecting] = useState(false);
+
+ async function handleConnect() {
+ if (!agentReady) {
+ toast.error("Seu agente ainda está sendo provisionado.");
+ return;
+ }
+ setConnecting(true);
+ const { data, error } = await invokeFunction<{ authorize_url: string }>(
+ "oauth-start",
+ { mcp_slug: mcp.slug },
+ );
+ setConnecting(false);
+ if (error || !data?.authorize_url) {
+ toast.error(error?.message ?? "Não foi possível iniciar a conexão.");
+ return;
+ }
+ window.location.href = data.authorize_url;
+ }
+
+ return (
+
+
+

+
+
+
{mcp.name}
+ {state.kind === "connected" && (
+
+ Conectado
+
+ )}
+ {state.kind === "error" && (
+
+
+ {state.integration.status === "expired"
+ ? "Expirado"
+ : state.integration.status === "revoked"
+ ? "Revogado"
+ : "Erro"}
+
+ )}
+ {state.kind === "locked" && (
+
+ Bloqueado
+
+ )}
+
+
+ {mcp.description}
+
+
+
+
+ {state.kind === "connected" && state.integration.connected_account_email && (
+
+ Conta: {state.integration.connected_account_email}
+
+ )}
+
+ {state.kind === "error" && state.integration.error_message && (
+
+ {state.integration.error_message}
+
+ )}
+
+ {state.kind === "locked" && (
+
+ Disponível nos planos: {state.mcp.available_in_plans.join(", ") || "—"}
+
+ )}
+
+
+ {state.kind === "available" && (
+
+ )}
+ {state.kind === "locked" && (
+
+ )}
+ {(state.kind === "connected" || state.kind === "error") && (
+
+ )}
+
+
+ );
+}
diff --git a/src/hooks/use-integrations.ts b/src/hooks/use-integrations.ts
new file mode 100644
index 0000000..fdd051c
--- /dev/null
+++ b/src/hooks/use-integrations.ts
@@ -0,0 +1,174 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/hooks/use-auth";
+import { useSubscription } from "@/hooks/use-profile";
+
+export interface AvailableMcp {
+ id: string;
+ slug: string;
+ name: string;
+ provider: string;
+ description: string;
+ icon_url: string;
+ oauth_authorize_url: string;
+ oauth_token_url: string;
+ oauth_revoke_url: string | null;
+ required_scopes: string[];
+ available_in_plans: string[];
+ supports_refresh_token: boolean;
+ display_order: number;
+ is_active: boolean;
+}
+
+export interface UserIntegration {
+ id: string;
+ user_id: string;
+ mcp_id: string;
+ status: "active" | "expired" | "revoked" | "error";
+ connected_account_email: string | null;
+ connected_account_name: string | null;
+ granted_scopes: string[];
+ token_expires_at: string | null;
+ last_refreshed_at: string | null;
+ error_message: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export type IntegrationCardState =
+ | { kind: "available"; mcp: AvailableMcp }
+ | { kind: "locked"; mcp: AvailableMcp; userPlan: string | null }
+ | { kind: "connected"; mcp: AvailableMcp; integration: UserIntegration }
+ | { kind: "error"; mcp: AvailableMcp; integration: UserIntegration };
+
+export function useAvailableMcps() {
+ return useQuery({
+ queryKey: ["available-mcps"],
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from("available_mcps")
+ .select("*")
+ .eq("is_active", true)
+ .order("display_order", { ascending: true });
+ if (error) throw error;
+ return (data ?? []).map((row) => ({
+ ...row,
+ required_scopes: Array.isArray(row.required_scopes)
+ ? (row.required_scopes as string[])
+ : [],
+ available_in_plans: Array.isArray(row.available_in_plans)
+ ? (row.available_in_plans as string[])
+ : [],
+ })) as AvailableMcp[];
+ },
+ });
+}
+
+export function useUserIntegrations() {
+ const { user } = useAuth();
+ return useQuery({
+ queryKey: ["user-integrations", user?.id],
+ enabled: !!user,
+ queryFn: async (): Promise => {
+ const { data, error } = await supabase
+ .from("user_integrations")
+ .select(
+ "id, user_id, mcp_id, status, connected_account_email, connected_account_name, granted_scopes, token_expires_at, last_refreshed_at, error_message, created_at, updated_at",
+ )
+ .eq("user_id", user!.id);
+ if (error) throw error;
+ return (data ?? []).map((row) => ({
+ ...row,
+ granted_scopes: Array.isArray(row.granted_scopes)
+ ? (row.granted_scopes as string[])
+ : [],
+ })) as UserIntegration[];
+ },
+ });
+}
+
+export function useUserIntegrationLimits() {
+ const { user } = useAuth();
+ return useQuery({
+ queryKey: ["user-integration-limits", user?.id],
+ enabled: !!user,
+ queryFn: async () => {
+ const { data, error } = await supabase
+ .from("user_integration_limits")
+ .select("*")
+ .eq("user_id", user!.id)
+ .maybeSingle();
+ if (error) throw error;
+ return data as {
+ user_id: string;
+ plan_slug: string | null;
+ current_integrations_count: number | null;
+ max_integrations: number | null;
+ } | null;
+ },
+ });
+}
+
+/**
+ * Combina available_mcps + user_integrations + plano em estados de cartão.
+ */
+export function useIntegrationCards() {
+ const mcpsQ = useAvailableMcps();
+ const integsQ = useUserIntegrations();
+ const subQ = useSubscription();
+
+ const userPlan = subQ.data?.plan_id ?? null;
+
+ // Para checagem de plano usamos a view de limites (que tem plan_slug derivado)
+ const limitsQ = useUserIntegrationLimits();
+ const planSlug = limitsQ.data?.plan_slug ?? null;
+
+ const isLoading = mcpsQ.isLoading || integsQ.isLoading || limitsQ.isLoading;
+ const error = mcpsQ.error ?? integsQ.error ?? limitsQ.error;
+
+ const cards: IntegrationCardState[] = (mcpsQ.data ?? []).map((mcp) => {
+ const integ = (integsQ.data ?? []).find((i) => i.mcp_id === mcp.id);
+ if (integ) {
+ if (integ.status === "active") {
+ return { kind: "connected", mcp, integration: integ };
+ }
+ return { kind: "error", mcp, integration: integ };
+ }
+ const planAllows = !planSlug || mcp.available_in_plans.length === 0
+ ? true
+ : mcp.available_in_plans.includes(planSlug);
+ if (!planAllows) {
+ return { kind: "locked", mcp, userPlan: planSlug };
+ }
+ return { kind: "available", mcp };
+ });
+
+ return {
+ cards,
+ isLoading,
+ error,
+ planSlug,
+ userPlan,
+ limit: limitsQ.data,
+ };
+}
+
+export function useDependentJobsForMcp(mcpSlug: string | undefined) {
+ const { user } = useAuth();
+ return useQuery({
+ queryKey: ["dependent-jobs", user?.id, mcpSlug],
+ enabled: !!user && !!mcpSlug,
+ queryFn: async (): Promise<{ id: string; name: string; status: string }[]> => {
+ const { data, error } = await supabase
+ .from("scheduled_jobs")
+ .select("id, name, status, required_mcp_slugs")
+ .eq("user_id", user!.id);
+ if (error) throw error;
+ return (data ?? [])
+ .filter((j) => Array.isArray(j.required_mcp_slugs) && (j.required_mcp_slugs as string[]).includes(mcpSlug!))
+ .map((j) => ({ id: j.id, name: j.name, status: j.status }));
+ },
+ });
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 025f321..96bac3a 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -22,9 +22,11 @@ import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configu
import { Route as PainelAgenteRouteImport } from './routes/painel.agente'
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
import { Route as PainelSkillsIndexRouteImport } from './routes/painel.skills.index'
+import { Route as PainelIntegracoesIndexRouteImport } from './routes/painel.integracoes.index'
import { Route as PainelSkillsPreviewRouteImport } from './routes/painel.skills.preview'
import { Route as PainelSkillsNovaRouteImport } from './routes/painel.skills.nova'
import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id'
+import { Route as PainelIntegracoesSlugRouteImport } from './routes/painel.integracoes.$slug'
const SignupRoute = SignupRouteImport.update({
id: '/signup',
@@ -91,6 +93,11 @@ const PainelSkillsIndexRoute = PainelSkillsIndexRouteImport.update({
path: '/',
getParentRoute: () => PainelSkillsRoute,
} as any)
+const PainelIntegracoesIndexRoute = PainelIntegracoesIndexRouteImport.update({
+ id: '/integracoes/',
+ path: '/integracoes/',
+ getParentRoute: () => PainelRoute,
+} as any)
const PainelSkillsPreviewRoute = PainelSkillsPreviewRouteImport.update({
id: '/preview',
path: '/preview',
@@ -106,6 +113,11 @@ const PainelSkillsIdRoute = PainelSkillsIdRouteImport.update({
path: '/$id',
getParentRoute: () => PainelSkillsRoute,
} as any)
+const PainelIntegracoesSlugRoute = PainelIntegracoesSlugRouteImport.update({
+ id: '/integracoes/$slug',
+ path: '/integracoes/$slug',
+ getParentRoute: () => PainelRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -120,9 +132,11 @@ export interface FileRoutesByFullPath {
'/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/skills': typeof PainelSkillsRouteWithChildren
'/painel/': typeof PainelIndexRoute
+ '/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
'/painel/skills/$id': typeof PainelSkillsIdRoute
'/painel/skills/nova': typeof PainelSkillsNovaRoute
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
+ '/painel/integracoes/': typeof PainelIntegracoesIndexRoute
'/painel/skills/': typeof PainelSkillsIndexRoute
}
export interface FileRoutesByTo {
@@ -136,9 +150,11 @@ export interface FileRoutesByTo {
'/painel/configuracoes': typeof PainelConfiguracoesRoute
'/painel/faturamento': typeof PainelFaturamentoRoute
'/painel': typeof PainelIndexRoute
+ '/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
'/painel/skills/$id': typeof PainelSkillsIdRoute
'/painel/skills/nova': typeof PainelSkillsNovaRoute
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
+ '/painel/integracoes': typeof PainelIntegracoesIndexRoute
'/painel/skills': typeof PainelSkillsIndexRoute
}
export interface FileRoutesById {
@@ -155,9 +171,11 @@ export interface FileRoutesById {
'/painel/faturamento': typeof PainelFaturamentoRoute
'/painel/skills': typeof PainelSkillsRouteWithChildren
'/painel/': typeof PainelIndexRoute
+ '/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
'/painel/skills/$id': typeof PainelSkillsIdRoute
'/painel/skills/nova': typeof PainelSkillsNovaRoute
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
+ '/painel/integracoes/': typeof PainelIntegracoesIndexRoute
'/painel/skills/': typeof PainelSkillsIndexRoute
}
export interface FileRouteTypes {
@@ -175,9 +193,11 @@ export interface FileRouteTypes {
| '/painel/faturamento'
| '/painel/skills'
| '/painel/'
+ | '/painel/integracoes/$slug'
| '/painel/skills/$id'
| '/painel/skills/nova'
| '/painel/skills/preview'
+ | '/painel/integracoes/'
| '/painel/skills/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -191,9 +211,11 @@ export interface FileRouteTypes {
| '/painel/configuracoes'
| '/painel/faturamento'
| '/painel'
+ | '/painel/integracoes/$slug'
| '/painel/skills/$id'
| '/painel/skills/nova'
| '/painel/skills/preview'
+ | '/painel/integracoes'
| '/painel/skills'
id:
| '__root__'
@@ -209,9 +231,11 @@ export interface FileRouteTypes {
| '/painel/faturamento'
| '/painel/skills'
| '/painel/'
+ | '/painel/integracoes/$slug'
| '/painel/skills/$id'
| '/painel/skills/nova'
| '/painel/skills/preview'
+ | '/painel/integracoes/'
| '/painel/skills/'
fileRoutesById: FileRoutesById
}
@@ -318,6 +342,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PainelSkillsIndexRouteImport
parentRoute: typeof PainelSkillsRoute
}
+ '/painel/integracoes/': {
+ id: '/painel/integracoes/'
+ path: '/integracoes'
+ fullPath: '/painel/integracoes/'
+ preLoaderRoute: typeof PainelIntegracoesIndexRouteImport
+ parentRoute: typeof PainelRoute
+ }
'/painel/skills/preview': {
id: '/painel/skills/preview'
path: '/preview'
@@ -339,6 +370,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PainelSkillsIdRouteImport
parentRoute: typeof PainelSkillsRoute
}
+ '/painel/integracoes/$slug': {
+ id: '/painel/integracoes/$slug'
+ path: '/integracoes/$slug'
+ fullPath: '/painel/integracoes/$slug'
+ preLoaderRoute: typeof PainelIntegracoesSlugRouteImport
+ parentRoute: typeof PainelRoute
+ }
}
}
@@ -366,6 +404,8 @@ interface PainelRouteChildren {
PainelFaturamentoRoute: typeof PainelFaturamentoRoute
PainelSkillsRoute: typeof PainelSkillsRouteWithChildren
PainelIndexRoute: typeof PainelIndexRoute
+ PainelIntegracoesSlugRoute: typeof PainelIntegracoesSlugRoute
+ PainelIntegracoesIndexRoute: typeof PainelIntegracoesIndexRoute
}
const PainelRouteChildren: PainelRouteChildren = {
@@ -374,6 +414,8 @@ const PainelRouteChildren: PainelRouteChildren = {
PainelFaturamentoRoute: PainelFaturamentoRoute,
PainelSkillsRoute: PainelSkillsRouteWithChildren,
PainelIndexRoute: PainelIndexRoute,
+ PainelIntegracoesSlugRoute: PainelIntegracoesSlugRoute,
+ PainelIntegracoesIndexRoute: PainelIntegracoesIndexRoute,
}
const PainelRouteWithChildren =
diff --git a/src/routes/painel.integracoes.$slug.tsx b/src/routes/painel.integracoes.$slug.tsx
new file mode 100644
index 0000000..0687ae2
--- /dev/null
+++ b/src/routes/painel.integracoes.$slug.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import { useState } from "react";
+import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ ArrowLeft,
+ RefreshCw,
+ CheckCircle2,
+ AlertCircle,
+ Clock,
+ Unplug,
+ Activity,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ useAvailableMcps,
+ useUserIntegrations,
+ useDependentJobsForMcp,
+} from "@/hooks/use-integrations";
+import { invokeFunction } from "@/lib/invoke-function";
+import { DisconnectMCPDialog } from "@/components/mika/integrations/DisconnectMCPDialog";
+
+export const Route = createFileRoute("/painel/integracoes/$slug")({
+ component: IntegrationDetailPage,
+});
+
+function formatPtBR(date: string | null): string {
+ if (!date) return "—";
+ try {
+ return new Intl.DateTimeFormat("pt-BR", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(date));
+ } catch {
+ return "—";
+ }
+}
+
+function IntegrationDetailPage() {
+ const { slug } = Route.useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { data: mcps = [], isLoading: mcpsLoading } = useAvailableMcps();
+ const { data: integs = [], isLoading: integsLoading } = useUserIntegrations();
+ const { data: dependentJobs = [] } = useDependentJobsForMcp(slug);
+
+ const [testing, setTesting] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [disconnectOpen, setDisconnectOpen] = useState(false);
+
+ const mcp = mcps.find((m) => m.slug === slug);
+ const integration = mcp ? integs.find((i) => i.mcp_id === mcp.id) : null;
+
+ if (mcpsLoading || integsLoading) {
+ return (
+
+ );
+ }
+
+ if (!mcp) {
+ return (
+
+
+
+
Integração não encontrada.
+
+
+ );
+ }
+
+ if (!integration) {
+ return (
+
+
+
+
+ Você ainda não conectou {mcp.name}.
+
+
+
+
+ );
+ }
+
+ async function handleTest() {
+ setTesting(true);
+ const { data, error } = await invokeFunction<{ ok: boolean; account?: string }>(
+ "test-integration",
+ { integration_id: integration!.id },
+ );
+ setTesting(false);
+ if (error) {
+ toast.error(error.message);
+ } else if (data?.ok) {
+ toast.success("Conexão funcionando perfeitamente.");
+ } else {
+ toast.warning("Conexão respondeu, mas com aviso. Verifique status.");
+ }
+ queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
+ }
+
+ async function handleRefresh() {
+ setRefreshing(true);
+ const { error } = await invokeFunction("refresh-integration-token", {
+ integration_id: integration!.id,
+ });
+ setRefreshing(false);
+ if (error) {
+ toast.error(error.message);
+ } else {
+ toast.success("Token renovado.");
+ }
+ queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
+ }
+
+ const status = integration.status;
+ const statusBadge =
+ status === "active" ? (
+
+ Ativo
+
+ ) : status === "expired" ? (
+
+ Expirado
+
+ ) : status === "revoked" ? (
+
+ Revogado
+
+ ) : (
+
+ Erro
+
+ );
+
+ return (
+
+
+
+
+
+

+
+
+
{mcp.name}
+ {statusBadge}
+
+
{mcp.description}
+
+
+
+ {integration.error_message && (
+
+ {integration.error_message}
+
+ )}
+
+
+
+
+
Conta conectada
+
+
+
- Email
+ - {integration.connected_account_email ?? "—"}
+
+ {integration.connected_account_name && (
+
+
- Nome
+ - {integration.connected_account_name}
+
+ )}
+
+
- Conectado em
+ - {formatPtBR(integration.created_at)}
+
+
+
- Última renovação
+ - {formatPtBR(integration.last_refreshed_at)}
+
+ {integration.token_expires_at && (
+
+
- Token expira em
+ - {formatPtBR(integration.token_expires_at)}
+
+ )}
+
+
+
+
+
Permissões concedidas
+ {integration.granted_scopes.length === 0 ? (
+
Nenhum escopo registrado.
+ ) : (
+
+ {integration.granted_scopes.map((s) => (
+ -
+ {s}
+
+ ))}
+
+ )}
+
+
+
+ {dependentJobs.length > 0 && (
+
+
+ Automações que usam esta integração
+
+
+ {dependentJobs.map((j) => (
+ -
+ {j.name}
+
+ {j.status}
+
+
+ ))}
+
+
+ )}
+
+
+
+ {mcp.supports_refresh_token && (
+
+ )}
+ {(status === "expired" || status === "revoked" || status === "error") && (
+
+ )}
+
+
+
+
{
+ setDisconnectOpen(o);
+ if (!o) {
+ // se desconectou, volta para a lista
+ queryClient
+ .invalidateQueries({ queryKey: ["user-integrations"] })
+ .then(() => {
+ const stillConnected = integs.some((i) => i.id === integration.id);
+ if (!stillConnected) {
+ navigate({ to: "/painel/integracoes" });
+ }
+ });
+ }
+ }}
+ integrationId={integration.id}
+ mcpSlug={mcp.slug}
+ mcpName={mcp.name}
+ />
+
+ );
+}
diff --git a/src/routes/painel.integracoes.index.tsx b/src/routes/painel.integracoes.index.tsx
new file mode 100644
index 0000000..98afa59
--- /dev/null
+++ b/src/routes/painel.integracoes.index.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { Plug } from "lucide-react";
+import { useEffect } from "react";
+import { toast } from "sonner";
+import { useQueryClient } from "@tanstack/react-query";
+import { useIntegrationCards } from "@/hooks/use-integrations";
+import { useAgentInstance } from "@/hooks/use-agent-instance";
+import { IntegrationCard } from "@/components/mika/integrations/IntegrationCard";
+
+export const Route = createFileRoute("/painel/integracoes/")({
+ validateSearch: (search: Record) => ({
+ status: typeof search.status === "string" ? search.status : undefined,
+ error: typeof search.error === "string" ? search.error : undefined,
+ mcp: typeof search.mcp === "string" ? search.mcp : undefined,
+ }),
+ component: IntegracoesPage,
+});
+
+function IntegracoesPage() {
+ const { cards, isLoading, error, limit } = useIntegrationCards();
+ const { data: agent } = useAgentInstance();
+ const agentReady = agent?.status === "active" || agent?.status === "ready";
+ const search = Route.useSearch();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (search.status === "success" && search.mcp) {
+ toast.success(`${search.mcp} conectado com sucesso!`);
+ queryClient.invalidateQueries({ queryKey: ["user-integrations"] });
+ queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] });
+ navigate({ to: "/painel/integracoes", search: {}, replace: true });
+ } else if (search.error) {
+ toast.error(`Erro ao conectar: ${search.error}`);
+ navigate({ to: "/painel/integracoes", search: {}, replace: true });
+ }
+ }, [search.status, search.error, search.mcp, navigate, queryClient]);
+
+ return (
+
+
+
+
+ Integrações
+
+
+ Conecte ferramentas externas ao seu agente Mika.
+
+
+ {limit && limit.max_integrations !== null && (
+
+
+ {limit.current_integrations_count ?? 0}
+
+ {" / "}
+ {limit.max_integrations} integrações
+
+ )}
+
+
+ {error && (
+
+ Erro ao carregar integrações: {error.message}
+
+ )}
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : cards.length === 0 ? (
+
+
Nenhuma integração disponível no momento.
+
+ ) : (
+
+ {cards.map((card) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/routes/painel.tsx b/src/routes/painel.tsx
index 60b3f75..f2b6520 100644
--- a/src/routes/painel.tsx
+++ b/src/routes/painel.tsx
@@ -59,7 +59,7 @@ export const Route = createFileRoute("/painel")({
});
interface NavItem {
- to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/faturamento" | "/painel/configuracoes";
+ to: "/painel" | "/painel/agente" | "/painel/skills" | "/painel/integracoes" | "/painel/faturamento" | "/painel/configuracoes";
label: string;
icon: React.ComponentType<{ className?: string }>;
disabled?: boolean;
@@ -76,7 +76,7 @@ const NAV: (NavItem | DisabledNavItem)[] = [
{ to: "/painel", label: "Dashboard", icon: Home },
{ to: "/painel/agente", label: "Meu Agente", icon: Bot },
{ to: "/painel/skills", label: "Skills", icon: Sparkles },
- { to: null, label: "Integrações", icon: Plug, disabled: true },
+ { to: "/painel/integracoes", label: "Integrações", icon: Plug },
{ to: "/painel/faturamento", label: "Faturamento", icon: CreditCard },
{ to: "/painel/configuracoes", label: "Configurações", icon: Settings },
];