mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 11:56:45 +00:00
Criou frontend de Integrações
X-Lovable-Edit-ID: edt-10d51684-ec05-41b3-9813-b9273e5b89e3 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
b588e49fa1
7 changed files with 861 additions and 2 deletions
122
src/components/mika/integrations/DisconnectMCPDialog.tsx
Normal file
122
src/components/mika/integrations/DisconnectMCPDialog.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Desconectar {mcpName}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>
|
||||||
|
A conexão será removida e os tokens serão revogados. Você pode reconectar
|
||||||
|
a qualquer momento.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Verificando automações dependentes...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && hasActiveJobs && (
|
||||||
|
<div className="rounded-md border border-destructive bg-destructive/10 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-destructive">
|
||||||
|
{activeJobs.length}{" "}
|
||||||
|
{activeJobs.length === 1 ? "automação ativa" : "automações ativas"}{" "}
|
||||||
|
depende{activeJobs.length === 1 ? "" : "m"} desta integração:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside mt-1">
|
||||||
|
{activeJobs.slice(0, 5).map((j) => (
|
||||||
|
<li key={j.id}>{j.name}</li>
|
||||||
|
))}
|
||||||
|
{activeJobs.length > 5 && (
|
||||||
|
<li>e mais {activeJobs.length - 5}...</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-xs">
|
||||||
|
Pause ou exclua essas automações antes de desconectar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={submitting}>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDisconnect();
|
||||||
|
}}
|
||||||
|
disabled={submitting || isLoading || hasActiveJobs}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{submitting ? "Desconectando..." : "Desconectar"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/mika/integrations/IntegrationCard.tsx
Normal file
118
src/components/mika/integrations/IntegrationCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft flex flex-col gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<img
|
||||||
|
src={mcp.icon_url}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-10 rounded-md object-contain bg-muted/40 p-1"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-semibold truncate">{mcp.name}</h3>
|
||||||
|
{state.kind === "connected" && (
|
||||||
|
<Badge variant="success" className="gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> Conectado
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{state.kind === "error" && (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{state.integration.status === "expired"
|
||||||
|
? "Expirado"
|
||||||
|
: state.integration.status === "revoked"
|
||||||
|
? "Revogado"
|
||||||
|
: "Erro"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{state.kind === "locked" && (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Lock className="h-3 w-3" /> Bloqueado
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||||
|
{mcp.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.kind === "connected" && state.integration.connected_account_email && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
Conta: <span className="font-mono">{state.integration.connected_account_email}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.kind === "error" && state.integration.error_message && (
|
||||||
|
<p className="text-xs text-destructive line-clamp-2">
|
||||||
|
{state.integration.error_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.kind === "locked" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Disponível nos planos: {state.mcp.available_in_plans.join(", ") || "—"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-auto">
|
||||||
|
{state.kind === "available" && (
|
||||||
|
<Button onClick={handleConnect} disabled={connecting || !agentReady} className="flex-1">
|
||||||
|
<Plug className="h-4 w-4 mr-2" />
|
||||||
|
{connecting ? "Conectando..." : "Conectar"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{state.kind === "locked" && (
|
||||||
|
<Button asChild variant="outline" className="flex-1">
|
||||||
|
<Link to="/painel/faturamento">Fazer upgrade</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(state.kind === "connected" || state.kind === "error") && (
|
||||||
|
<Button asChild variant="outline" className="flex-1">
|
||||||
|
<Link to="/painel/integracoes/$slug" params={{ slug: mcp.slug }}>
|
||||||
|
Gerenciar
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/hooks/use-integrations.ts
Normal file
174
src/hooks/use-integrations.ts
Normal file
|
|
@ -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<AvailableMcp[]> => {
|
||||||
|
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<UserIntegration[]> => {
|
||||||
|
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 }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,11 @@ import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configu
|
||||||
import { Route as PainelAgenteRouteImport } from './routes/painel.agente'
|
import { Route as PainelAgenteRouteImport } from './routes/painel.agente'
|
||||||
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
|
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
|
||||||
import { Route as PainelSkillsIndexRouteImport } from './routes/painel.skills.index'
|
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 PainelSkillsPreviewRouteImport } from './routes/painel.skills.preview'
|
||||||
import { Route as PainelSkillsNovaRouteImport } from './routes/painel.skills.nova'
|
import { Route as PainelSkillsNovaRouteImport } from './routes/painel.skills.nova'
|
||||||
import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id'
|
import { Route as PainelSkillsIdRouteImport } from './routes/painel.skills.$id'
|
||||||
|
import { Route as PainelIntegracoesSlugRouteImport } from './routes/painel.integracoes.$slug'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const SignupRoute = SignupRouteImport.update({
|
||||||
id: '/signup',
|
id: '/signup',
|
||||||
|
|
@ -91,6 +93,11 @@ const PainelSkillsIndexRoute = PainelSkillsIndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => PainelSkillsRoute,
|
getParentRoute: () => PainelSkillsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PainelIntegracoesIndexRoute = PainelIntegracoesIndexRouteImport.update({
|
||||||
|
id: '/integracoes/',
|
||||||
|
path: '/integracoes/',
|
||||||
|
getParentRoute: () => PainelRoute,
|
||||||
|
} as any)
|
||||||
const PainelSkillsPreviewRoute = PainelSkillsPreviewRouteImport.update({
|
const PainelSkillsPreviewRoute = PainelSkillsPreviewRouteImport.update({
|
||||||
id: '/preview',
|
id: '/preview',
|
||||||
path: '/preview',
|
path: '/preview',
|
||||||
|
|
@ -106,6 +113,11 @@ const PainelSkillsIdRoute = PainelSkillsIdRouteImport.update({
|
||||||
path: '/$id',
|
path: '/$id',
|
||||||
getParentRoute: () => PainelSkillsRoute,
|
getParentRoute: () => PainelSkillsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PainelIntegracoesSlugRoute = PainelIntegracoesSlugRouteImport.update({
|
||||||
|
id: '/integracoes/$slug',
|
||||||
|
path: '/integracoes/$slug',
|
||||||
|
getParentRoute: () => PainelRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -120,9 +132,11 @@ export interface FileRoutesByFullPath {
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
||||||
'/painel/': typeof PainelIndexRoute
|
'/painel/': typeof PainelIndexRoute
|
||||||
|
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
||||||
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
||||||
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
||||||
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
||||||
|
'/painel/integracoes/': typeof PainelIntegracoesIndexRoute
|
||||||
'/painel/skills/': typeof PainelSkillsIndexRoute
|
'/painel/skills/': typeof PainelSkillsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
|
|
@ -136,9 +150,11 @@ export interface FileRoutesByTo {
|
||||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
'/painel': typeof PainelIndexRoute
|
'/painel': typeof PainelIndexRoute
|
||||||
|
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
||||||
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
||||||
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
||||||
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
||||||
|
'/painel/integracoes': typeof PainelIntegracoesIndexRoute
|
||||||
'/painel/skills': typeof PainelSkillsIndexRoute
|
'/painel/skills': typeof PainelSkillsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
|
|
@ -155,9 +171,11 @@ export interface FileRoutesById {
|
||||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||||
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
'/painel/skills': typeof PainelSkillsRouteWithChildren
|
||||||
'/painel/': typeof PainelIndexRoute
|
'/painel/': typeof PainelIndexRoute
|
||||||
|
'/painel/integracoes/$slug': typeof PainelIntegracoesSlugRoute
|
||||||
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
'/painel/skills/$id': typeof PainelSkillsIdRoute
|
||||||
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
'/painel/skills/nova': typeof PainelSkillsNovaRoute
|
||||||
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
'/painel/skills/preview': typeof PainelSkillsPreviewRoute
|
||||||
|
'/painel/integracoes/': typeof PainelIntegracoesIndexRoute
|
||||||
'/painel/skills/': typeof PainelSkillsIndexRoute
|
'/painel/skills/': typeof PainelSkillsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
|
|
@ -175,9 +193,11 @@ export interface FileRouteTypes {
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
| '/painel/skills'
|
| '/painel/skills'
|
||||||
| '/painel/'
|
| '/painel/'
|
||||||
|
| '/painel/integracoes/$slug'
|
||||||
| '/painel/skills/$id'
|
| '/painel/skills/$id'
|
||||||
| '/painel/skills/nova'
|
| '/painel/skills/nova'
|
||||||
| '/painel/skills/preview'
|
| '/painel/skills/preview'
|
||||||
|
| '/painel/integracoes/'
|
||||||
| '/painel/skills/'
|
| '/painel/skills/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
|
|
@ -191,9 +211,11 @@ export interface FileRouteTypes {
|
||||||
| '/painel/configuracoes'
|
| '/painel/configuracoes'
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
| '/painel'
|
| '/painel'
|
||||||
|
| '/painel/integracoes/$slug'
|
||||||
| '/painel/skills/$id'
|
| '/painel/skills/$id'
|
||||||
| '/painel/skills/nova'
|
| '/painel/skills/nova'
|
||||||
| '/painel/skills/preview'
|
| '/painel/skills/preview'
|
||||||
|
| '/painel/integracoes'
|
||||||
| '/painel/skills'
|
| '/painel/skills'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
|
|
@ -209,9 +231,11 @@ export interface FileRouteTypes {
|
||||||
| '/painel/faturamento'
|
| '/painel/faturamento'
|
||||||
| '/painel/skills'
|
| '/painel/skills'
|
||||||
| '/painel/'
|
| '/painel/'
|
||||||
|
| '/painel/integracoes/$slug'
|
||||||
| '/painel/skills/$id'
|
| '/painel/skills/$id'
|
||||||
| '/painel/skills/nova'
|
| '/painel/skills/nova'
|
||||||
| '/painel/skills/preview'
|
| '/painel/skills/preview'
|
||||||
|
| '/painel/integracoes/'
|
||||||
| '/painel/skills/'
|
| '/painel/skills/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
|
|
@ -318,6 +342,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof PainelSkillsIndexRouteImport
|
preLoaderRoute: typeof PainelSkillsIndexRouteImport
|
||||||
parentRoute: typeof PainelSkillsRoute
|
parentRoute: typeof PainelSkillsRoute
|
||||||
}
|
}
|
||||||
|
'/painel/integracoes/': {
|
||||||
|
id: '/painel/integracoes/'
|
||||||
|
path: '/integracoes'
|
||||||
|
fullPath: '/painel/integracoes/'
|
||||||
|
preLoaderRoute: typeof PainelIntegracoesIndexRouteImport
|
||||||
|
parentRoute: typeof PainelRoute
|
||||||
|
}
|
||||||
'/painel/skills/preview': {
|
'/painel/skills/preview': {
|
||||||
id: '/painel/skills/preview'
|
id: '/painel/skills/preview'
|
||||||
path: '/preview'
|
path: '/preview'
|
||||||
|
|
@ -339,6 +370,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof PainelSkillsIdRouteImport
|
preLoaderRoute: typeof PainelSkillsIdRouteImport
|
||||||
parentRoute: typeof PainelSkillsRoute
|
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
|
PainelFaturamentoRoute: typeof PainelFaturamentoRoute
|
||||||
PainelSkillsRoute: typeof PainelSkillsRouteWithChildren
|
PainelSkillsRoute: typeof PainelSkillsRouteWithChildren
|
||||||
PainelIndexRoute: typeof PainelIndexRoute
|
PainelIndexRoute: typeof PainelIndexRoute
|
||||||
|
PainelIntegracoesSlugRoute: typeof PainelIntegracoesSlugRoute
|
||||||
|
PainelIntegracoesIndexRoute: typeof PainelIntegracoesIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const PainelRouteChildren: PainelRouteChildren = {
|
const PainelRouteChildren: PainelRouteChildren = {
|
||||||
|
|
@ -374,6 +414,8 @@ const PainelRouteChildren: PainelRouteChildren = {
|
||||||
PainelFaturamentoRoute: PainelFaturamentoRoute,
|
PainelFaturamentoRoute: PainelFaturamentoRoute,
|
||||||
PainelSkillsRoute: PainelSkillsRouteWithChildren,
|
PainelSkillsRoute: PainelSkillsRouteWithChildren,
|
||||||
PainelIndexRoute: PainelIndexRoute,
|
PainelIndexRoute: PainelIndexRoute,
|
||||||
|
PainelIntegracoesSlugRoute: PainelIntegracoesSlugRoute,
|
||||||
|
PainelIntegracoesIndexRoute: PainelIntegracoesIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PainelRouteWithChildren =
|
const PainelRouteWithChildren =
|
||||||
|
|
|
||||||
312
src/routes/painel.integracoes.$slug.tsx
Normal file
312
src/routes/painel.integracoes.$slug.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mcp) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/painel/integracoes">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-8 text-center">
|
||||||
|
<p className="text-muted-foreground">Integração não encontrada.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/painel/integracoes">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-8 text-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Você ainda não conectou {mcp.name}.
|
||||||
|
</p>
|
||||||
|
<Button asChild className="mt-4">
|
||||||
|
<Link to="/painel/integracoes">Conectar</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" ? (
|
||||||
|
<Badge variant="success" className="gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> Ativo
|
||||||
|
</Badge>
|
||||||
|
) : status === "expired" ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Clock className="h-3 w-3" /> Expirado
|
||||||
|
</Badge>
|
||||||
|
) : status === "revoked" ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" /> Revogado
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" /> Erro
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/painel/integracoes">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" /> Voltar
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<img
|
||||||
|
src={mcp.icon_url}
|
||||||
|
alt=""
|
||||||
|
className="h-14 w-14 rounded-lg object-contain bg-muted/40 p-2"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold">{mcp.name}</h1>
|
||||||
|
{statusBadge}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{mcp.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{integration.error_message && (
|
||||||
|
<div className="mt-4 rounded-md border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{integration.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-3">
|
||||||
|
<h2 className="font-semibold">Conta conectada</h2>
|
||||||
|
<dl className="text-sm space-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Email</dt>
|
||||||
|
<dd className="font-mono">{integration.connected_account_email ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
{integration.connected_account_name && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Nome</dt>
|
||||||
|
<dd>{integration.connected_account_name}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Conectado em</dt>
|
||||||
|
<dd>{formatPtBR(integration.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Última renovação</dt>
|
||||||
|
<dd>{formatPtBR(integration.last_refreshed_at)}</dd>
|
||||||
|
</div>
|
||||||
|
{integration.token_expires_at && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Token expira em</dt>
|
||||||
|
<dd>{formatPtBR(integration.token_expires_at)}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft space-y-3">
|
||||||
|
<h2 className="font-semibold">Permissões concedidas</h2>
|
||||||
|
{integration.granted_scopes.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Nenhum escopo registrado.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="text-xs font-mono space-y-1 max-h-48 overflow-auto">
|
||||||
|
{integration.granted_scopes.map((s) => (
|
||||||
|
<li key={s} className="text-muted-foreground break-all">
|
||||||
|
{s}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dependentJobs.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5 shadow-soft">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2 mb-3">
|
||||||
|
<Activity className="h-4 w-4" /> Automações que usam esta integração
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{dependentJobs.map((j) => (
|
||||||
|
<li
|
||||||
|
key={j.id}
|
||||||
|
className="flex items-center justify-between text-sm border-b border-border last:border-0 pb-2 last:pb-0"
|
||||||
|
>
|
||||||
|
<span>{j.name}</span>
|
||||||
|
<Badge variant={j.status === "active" ? "success" : "secondary"}>
|
||||||
|
{j.status}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={handleTest} disabled={testing} variant="outline">
|
||||||
|
<Activity className="h-4 w-4 mr-2" />
|
||||||
|
{testing ? "Testando..." : "Testar conexão"}
|
||||||
|
</Button>
|
||||||
|
{mcp.supports_refresh_token && (
|
||||||
|
<Button onClick={handleRefresh} disabled={refreshing} variant="outline">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
{refreshing ? "Renovando..." : "Renovar token"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(status === "expired" || status === "revoked" || status === "error") && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
const { data, error } = await invokeFunction<{ authorize_url: string }>(
|
||||||
|
"oauth-start",
|
||||||
|
{ mcp_slug: mcp.slug },
|
||||||
|
);
|
||||||
|
if (error || !data?.authorize_url) {
|
||||||
|
toast.error(error?.message ?? "Falha ao iniciar reconexão.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = data.authorize_url;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reconectar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDisconnectOpen(true)}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
<Unplug className="h-4 w-4 mr-2" /> Desconectar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DisconnectMCPDialog
|
||||||
|
open={disconnectOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/routes/painel.integracoes.index.tsx
Normal file
91
src/routes/painel.integracoes.index.tsx
Normal file
|
|
@ -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<string, unknown>) => ({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Plug className="h-6 w-6 text-primary" /> Integrações
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Conecte ferramentas externas ao seu agente Mika.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{limit && limit.max_integrations !== null && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{limit.current_integrations_count ?? 0}
|
||||||
|
</span>
|
||||||
|
{" / "}
|
||||||
|
{limit.max_integrations} integrações
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
Erro ao carregar integrações: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-44 rounded-xl border border-border bg-muted/30 animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : cards.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-border p-12 text-center">
|
||||||
|
<p className="text-muted-foreground">Nenhuma integração disponível no momento.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<IntegrationCard key={card.mcp.id} state={card} agentReady={!!agentReady} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -59,7 +59,7 @@ export const Route = createFileRoute("/painel")({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface NavItem {
|
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;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -76,7 +76,7 @@ const NAV: (NavItem | DisabledNavItem)[] = [
|
||||||
{ to: "/painel", label: "Dashboard", icon: Home },
|
{ to: "/painel", label: "Dashboard", icon: Home },
|
||||||
{ to: "/painel/agente", label: "Meu Agente", icon: Bot },
|
{ to: "/painel/agente", label: "Meu Agente", icon: Bot },
|
||||||
{ to: "/painel/skills", label: "Skills", icon: Sparkles },
|
{ 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/faturamento", label: "Faturamento", icon: CreditCard },
|
||||||
{ to: "/painel/configuracoes", label: "Configurações", icon: Settings },
|
{ to: "/painel/configuracoes", label: "Configurações", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue