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 }, ];