diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index e613a8d..dd06ff5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' import { Route as PainelIndexRouteImport } from './routes/painel.index' +import { Route as AdminIndexRouteImport } from './routes/admin.index' import { Route as PainelSkillsRouteImport } from './routes/painel.skills' import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento' import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes' @@ -73,6 +74,11 @@ const PainelIndexRoute = PainelIndexRouteImport.update({ path: '/', getParentRoute: () => PainelRoute, } as any) +const AdminIndexRoute = AdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminRoute, +} as any) const PainelSkillsRoute = PainelSkillsRouteImport.update({ id: '/skills', path: '/skills', @@ -162,6 +168,7 @@ export interface FileRoutesByFullPath { '/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/faturamento': typeof PainelFaturamentoRoute '/painel/skills': typeof PainelSkillsRouteWithChildren + '/admin/': typeof AdminIndexRoute '/painel/': typeof PainelIndexRoute '/admin/agente/$id': typeof AdminAgenteIdRoute '/painel/cronjobs/$id': typeof PainelCronjobsIdRoute @@ -176,7 +183,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/admin': typeof AdminRouteWithChildren '/login': typeof LoginRoute '/recuperar-senha': typeof RecuperarSenhaRoute '/redefinir-senha': typeof RedefinirSenhaRoute @@ -185,6 +191,7 @@ export interface FileRoutesByTo { '/painel/agente': typeof PainelAgenteRoute '/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/faturamento': typeof PainelFaturamentoRoute + '/admin': typeof AdminIndexRoute '/painel': typeof PainelIndexRoute '/admin/agente/$id': typeof AdminAgenteIdRoute '/painel/cronjobs/$id': typeof PainelCronjobsIdRoute @@ -211,6 +218,7 @@ export interface FileRoutesById { '/painel/configuracoes': typeof PainelConfiguracoesRoute '/painel/faturamento': typeof PainelFaturamentoRoute '/painel/skills': typeof PainelSkillsRouteWithChildren + '/admin/': typeof AdminIndexRoute '/painel/': typeof PainelIndexRoute '/admin/agente/$id': typeof AdminAgenteIdRoute '/painel/cronjobs/$id': typeof PainelCronjobsIdRoute @@ -238,6 +246,7 @@ export interface FileRouteTypes { | '/painel/configuracoes' | '/painel/faturamento' | '/painel/skills' + | '/admin/' | '/painel/' | '/admin/agente/$id' | '/painel/cronjobs/$id' @@ -252,7 +261,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' - | '/admin' | '/login' | '/recuperar-senha' | '/redefinir-senha' @@ -261,6 +269,7 @@ export interface FileRouteTypes { | '/painel/agente' | '/painel/configuracoes' | '/painel/faturamento' + | '/admin' | '/painel' | '/admin/agente/$id' | '/painel/cronjobs/$id' @@ -286,6 +295,7 @@ export interface FileRouteTypes { | '/painel/configuracoes' | '/painel/faturamento' | '/painel/skills' + | '/admin/' | '/painel/' | '/admin/agente/$id' | '/painel/cronjobs/$id' @@ -368,6 +378,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PainelIndexRouteImport parentRoute: typeof PainelRoute } + '/admin/': { + id: '/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AdminIndexRouteImport + parentRoute: typeof AdminRoute + } '/painel/skills': { id: '/painel/skills' path: '/skills' @@ -477,10 +494,12 @@ declare module '@tanstack/react-router' { } interface AdminRouteChildren { + AdminIndexRoute: typeof AdminIndexRoute AdminAgenteIdRoute: typeof AdminAgenteIdRoute } const AdminRouteChildren: AdminRouteChildren = { + AdminIndexRoute: AdminIndexRoute, AdminAgenteIdRoute: AdminAgenteIdRoute, } diff --git a/src/routes/admin.index.tsx b/src/routes/admin.index.tsx new file mode 100644 index 0000000..df9cdd1 --- /dev/null +++ b/src/routes/admin.index.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + ArrowLeft, + Loader2, + PlayCircle, + PauseCircle, + Server, + Settings, + ShieldAlert, +} from "lucide-react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { invokeFunction } from "@/lib/invoke-function"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export const Route = createFileRoute("/admin/")({ + component: AdminPage, +}); + +interface AgentRow { + id: string; + user_id: string; + status: string; + uuid_tenant: string; + telegram_bot_username: string | null; + telegram_bot_token_vault_id: string | null; + railway_service_id: string | null; + vps_pool_id: string | null; + created_at: string; + provisioned_at: string | null; + profile: { full_name: string | null } | null; + subscription: { plans: { slug: string; name: string } | null } | null; +} + +function AdminPage() { + const { user, loading: authLoading } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [busy, setBusy] = useState(null); + + const { data: isAdmin, isLoading: roleLoading } = useQuery({ + queryKey: ["is-admin", user?.id], + enabled: !!user, + queryFn: async () => { + const { data, error } = await supabase.rpc("has_role", { + _user_id: user!.id, + _role: "admin", + }); + if (error) throw error; + return data === true; + }, + }); + + const { data: agents, isLoading: agentsLoading } = useQuery({ + queryKey: ["agents-admin"], + enabled: !!isAdmin, + refetchInterval: 15000, + queryFn: async () => { + const { data: agentsData, error: agentsError } = await supabase + .from("agent_instances") + .select(`*, profile:profiles!agent_instances_user_id_fkey(full_name, phone)`) + .order("created_at", { ascending: false }) + .limit(100); + if (agentsError) throw agentsError; + + const userIds = [...new Set((agentsData ?? []).map((agent) => agent.user_id).filter(Boolean))]; + + const { data: subscriptionsData, error: subscriptionsError } = userIds.length + ? await supabase + .from("subscriptions") + .select("user_id, status, plans(name, slug)") + .in("user_id", userIds) + .order("created_at", { ascending: false }) + : { data: [], error: null }; + if (subscriptionsError) throw subscriptionsError; + + const subscriptionsByUserId = new Map( + (subscriptionsData ?? []).map((subscription) => [subscription.user_id, subscription]), + ); + + // deno-lint-ignore no-explicit-any + return (agentsData as any[]).map((agent) => ({ + ...agent, + profile: Array.isArray(agent.profile) ? agent.profile[0] ?? null : agent.profile, + subscription: subscriptionsByUserId.get(agent.user_id) ?? null, + })) as AgentRow[]; + }, + }); + + useEffect(() => { + if (!authLoading && !user) { + navigate({ to: "/login", search: { redirect: "/admin" } }); + } + }, [authLoading, user, navigate]); + + if (authLoading || roleLoading) { + return ( +
+ +
+ ); + } + + if (!isAdmin) { + return ( +
+
+
+ +
+

Acesso negado

+

+ Esta área é restrita a administradores do Mika. +

+ +
+
+ ); + } + + async function action(fn: "suspend-agent" | "resume-agent", agentId: string) { + setBusy(agentId + fn); + const { data, error } = await invokeFunction<{ ok?: boolean; error?: string }>(fn, { + agent_instance_id: agentId, + }); + setBusy(null); + if (error) { + toast.error(`${fn} falhou: ${error.message}`); + } else if (data?.error) { + toast.error(`${fn}: ${data.error}`); + } else { + toast.success(`${fn} executado com sucesso`); + queryClient.invalidateQueries({ queryKey: ["agents-admin"] }); + } + } + + return ( +
+
+
+
+

+ Admin · Mika +

+

+ Configure e gerencie agentes provisionados. +

+
+ +
+ +
+

Agentes ({agents?.length ?? 0})

+ {agentsLoading ? ( + + ) : !agents?.length ? ( +

+ Nenhum agente cadastrado ainda. +

+ ) : ( +
+ + + + Tenant + Cliente + Bot + Plano + Status + Railway + Ações + + + + {agents.map((a) => ( + + + {a.uuid_tenant.slice(0, 8)} + + + {a.profile?.full_name || "—"} + + + {a.telegram_bot_username ? `@${a.telegram_bot_username}` : "—"} + + + + + + + + + {a.railway_service_id?.slice(0, 8) ?? "—"} + + + + {a.status === "active" && ( + + )} + {a.status === "suspended" && ( + + )} + + + ))} + +
+
+ )} +
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + if (status === "active") return Ativo; + if (status === "provisioning") return Provisionando; + if (status === "suspended") return Suspenso; + if (status === "error") return Erro; + return {status}; +} + +function PlanBadge({ slug }: { slug: string | null }) { + if (!slug) return Sem plano; + if (slug === "professional" || slug === "enterprise") + return {slug}; + if (slug === "starter") return {slug}; + return {slug}; +} + diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 8cd957f..d816c36 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,283 +1,11 @@ "use client"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { - ArrowLeft, - Loader2, - PlayCircle, - PauseCircle, - Server, - Settings, - ShieldAlert, -} from "lucide-react"; -import { supabase } from "@/integrations/supabase/client"; -import { useAuth } from "@/hooks/use-auth"; -import { invokeFunction } from "@/lib/invoke-function"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/admin")({ - component: AdminPage, + component: AdminLayout, }); -interface AgentRow { - id: string; - user_id: string; - status: string; - uuid_tenant: string; - telegram_bot_username: string | null; - telegram_bot_token_vault_id: string | null; - railway_service_id: string | null; - vps_pool_id: string | null; - created_at: string; - provisioned_at: string | null; - profile: { full_name: string | null } | null; - subscription: { plans: { slug: string; name: string } | null } | null; -} - -function AdminPage() { - const { user, loading: authLoading } = useAuth(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [busy, setBusy] = useState(null); - - const { data: isAdmin, isLoading: roleLoading } = useQuery({ - queryKey: ["is-admin", user?.id], - enabled: !!user, - queryFn: async () => { - const { data, error } = await supabase.rpc("has_role", { - _user_id: user!.id, - _role: "admin", - }); - if (error) throw error; - return data === true; - }, - }); - - const { data: agents, isLoading: agentsLoading } = useQuery({ - queryKey: ["agents-admin"], - enabled: !!isAdmin, - refetchInterval: 15000, - queryFn: async () => { - const { data: agentsData, error: agentsError } = await supabase - .from("agent_instances") - .select(`*, profile:profiles!agent_instances_user_id_fkey(full_name, phone)`) - .order("created_at", { ascending: false }) - .limit(100); - if (agentsError) throw agentsError; - - const userIds = [...new Set((agentsData ?? []).map((agent) => agent.user_id).filter(Boolean))]; - - const { data: subscriptionsData, error: subscriptionsError } = userIds.length - ? await supabase - .from("subscriptions") - .select("user_id, status, plans(name, slug)") - .in("user_id", userIds) - .order("created_at", { ascending: false }) - : { data: [], error: null }; - if (subscriptionsError) throw subscriptionsError; - - const subscriptionsByUserId = new Map( - (subscriptionsData ?? []).map((subscription) => [subscription.user_id, subscription]), - ); - - // deno-lint-ignore no-explicit-any - return (agentsData as any[]).map((agent) => ({ - ...agent, - profile: Array.isArray(agent.profile) ? agent.profile[0] ?? null : agent.profile, - subscription: subscriptionsByUserId.get(agent.user_id) ?? null, - })) as AgentRow[]; - }, - }); - - useEffect(() => { - if (!authLoading && !user) { - navigate({ to: "/login", search: { redirect: "/admin" } }); - } - }, [authLoading, user, navigate]); - - if (authLoading || roleLoading) { - return ( -
- -
- ); - } - - if (!isAdmin) { - return ( -
-
-
- -
-

Acesso negado

-

- Esta área é restrita a administradores do Mika. -

- -
-
- ); - } - - async function action(fn: "suspend-agent" | "resume-agent", agentId: string) { - setBusy(agentId + fn); - const { data, error } = await invokeFunction<{ ok?: boolean; error?: string }>(fn, { - agent_instance_id: agentId, - }); - setBusy(null); - if (error) { - toast.error(`${fn} falhou: ${error.message}`); - } else if (data?.error) { - toast.error(`${fn}: ${data.error}`); - } else { - toast.success(`${fn} executado com sucesso`); - queryClient.invalidateQueries({ queryKey: ["agents-admin"] }); - } - } - - return ( -
-
-
-
-

- Admin · Mika -

-

- Configure e gerencie agentes provisionados. -

-
- -
- -
-

Agentes ({agents?.length ?? 0})

- {agentsLoading ? ( - - ) : !agents?.length ? ( -

- Nenhum agente cadastrado ainda. -

- ) : ( -
- - - - Tenant - Cliente - Bot - Plano - Status - Railway - Ações - - - - {agents.map((a) => ( - - - {a.uuid_tenant.slice(0, 8)} - - - {a.profile?.full_name || "—"} - - - {a.telegram_bot_username ? `@${a.telegram_bot_username}` : "—"} - - - - - - - - - {a.railway_service_id?.slice(0, 8) ?? "—"} - - - - {a.status === "active" && ( - - )} - {a.status === "suspended" && ( - - )} - - - ))} - -
-
- )} -
-
-
- ); -} - -function StatusBadge({ status }: { status: string }) { - if (status === "active") return Ativo; - if (status === "provisioning") return Provisionando; - if (status === "suspended") return Suspenso; - if (status === "error") return Erro; - return {status}; -} - -function PlanBadge({ slug }: { slug: string | null }) { - if (!slug) return Sem plano; - if (slug === "professional" || slug === "enterprise") - return {slug}; - if (slug === "starter") return {slug}; - return {slug}; -} - +function AdminLayout() { + return ; +} \ No newline at end of file