15 Commits

Author SHA1 Message Date
Vitex Tecnologia 7a01c6f97b Atualizacoes: Novo modo leitura de Parecer, campo instrucao agora é opcional, campo do agente do parecer pode ser recolhido, ajuste na cor do botão de agente de prompt 2026-04-14 14:42:33 -03:00
Vitex Tecnologia 8f2e4f1d21 Ajustes e melhorias no Codex 2026-03-23 18:24:08 -03:00
Vitex Tecnologia 8d8818c32e nova implementacao modo chat e agente no Parecer 2026-03-20 13:12:17 -03:00
Vitex Tecnologia 6537e2dd53 atualizacoes codex 2026-03-20 00:02:12 -03:00
Vitex Tecnologia 6bf7e7d42e Nova versao codex Parecer, atualizacao completa 2026-03-18 01:07:27 -03:00
developer1054 39032ea621 Merge commit '71f9c9ab860f7c5597427fb5fc82492a925aa9cf' 2026-01-19 14:27:07 -03:00
Vitex Tecnologia 71f9c9ab86 mudanca requisicoes API do metodo PUT para POST e limpeza / organizacao dos códigos 2026-01-19 14:19:18 -03:00
developer1054 d75d80e9c7 build 2026-01-15 10:08:47 -03:00
developer1054 189a2a8f2c Merge commit '76cbd80eefee58b1335cf335ef42eaa3570c26ae' 2026-01-15 09:38:02 -03:00
developer1054 50aca767d7 remove commented-out code for bots tab in Layout component 2026-01-15 09:37:15 -03:00
Vitex Tecnologia 76cbd80eef modulo de agente pessoal de IA 2026-01-14 20:10:42 -03:00
luisfepsale ee7204982c [change/tempo de requisição do parecer] 2025-11-24 09:26:33 -03:00
luisfepsale a28b780718 [new/parecer juridico] 2025-11-19 15:19:41 -03:00
developer1054 0fa382ef0e Merge branch 'main' of https://git.hgtx.com.br/luis.felipe/OPEN_CODEX_API 2025-10-28 11:34:30 -03:00
developer1054 c2aa536660 fix redir 2025-10-28 11:34:25 -03:00
73 changed files with 12347 additions and 2035 deletions
BIN
View File
Binary file not shown.
+4
View File
@@ -10,11 +10,15 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}"
} }
} }
+1410 -9
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -32,7 +32,7 @@
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-toast": "^1.2.14",
@@ -40,7 +40,9 @@
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
"@types/canvas-confetti": "^1.9.0",
"axios": "^1.12.2", "axios": "^1.12.2",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -49,11 +51,13 @@
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"motion": "^12.36.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.61.1", "react-hook-form": "^7.61.1",
"react-markdown": "^9.1.0",
"react-resizable-panels": "^2.1.9", "react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
+14 -3
View File
@@ -9,12 +9,20 @@ import NotFound from "./pages/NotFound";
import Redirect from "./pages/Redirect"; import Redirect from "./pages/Redirect";
import { GlobalFunctions } from "./GlobalFunctions"; import { GlobalFunctions } from "./GlobalFunctions";
import React from "react"; import React from "react";
import IntelligenceIAApp from "./modules/intelligence-ia/App";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const App = () => ( const App = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}> <ThemeProvider
attribute="class"
defaultTheme="light"
themes={["light", "dark"]}
enableSystem={false}
storageKey="hgtx-codex-theme"
enableColorScheme
>
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Sonner /> <Sonner />
@@ -25,8 +33,11 @@ const App = () => (
element={<Redirect />} element={<Redirect />}
/> />
<Route path="/*" element={GlobalFunctions.isUsuarioLogado() ? <Index /> : <HGTXLogin />} /> {/* Módulo Intelligence IA como subpath*/}
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} <Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
<Route path="/*" element={<Index />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
<Route path="/404" element={<NotFound />} /> <Route path="/404" element={<NotFound />} />
</Routes> </Routes>
+8 -6
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain } from "lucide-react"; import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, FileText, Layers } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
@@ -7,11 +7,11 @@ import { GlobalFunctions } from "@/GlobalFunctions";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent"; activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent") => void; onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas") => void;
} }
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "agent"; type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => { export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@@ -21,8 +21,10 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
{ id: "images" as TabType, label: "Imagens", icon: Image }, { id: "images" as TabType, label: "Imagens", icon: Image },
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines }, { id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic }, { id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
{ id: "bots" as TabType, label: "Bots", icon: Bot }, //{ id: "bots" as TabType, label: "Bots", icon: Bot },
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Brain }, { id: "prompts" as TabType, label: "Prompts", icon: FileText },
{ id: "parecerJuridico" as TabType, label: "Parecer Jurídico", icon: Bot },
{ id: "areas" as TabType, label: "Áreas", icon: Layers },
]; ];
return ( return (
+77 -12
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Plus, Search, Eye, Trash2, ArrowUpDown, Download } from "lucide-react"; import { Plus, Search, Eye, Trash2, ArrowUpDown, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { import {
Table, Table,
TableBody, TableBody,
@@ -43,6 +44,7 @@ type SortOrder = "asc" | "desc";
export const AgentView = () => { export const AgentView = () => {
const [opinions, setOpinions] = useState<OpinionRecord[]>([]); const [opinions, setOpinions] = useState<OpinionRecord[]>([]);
const [pendingOpinions, setPendingOpinions] = useState<OpinionRecord[]>([]); // Registros temporários sendo processados
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -57,7 +59,7 @@ export const AgentView = () => {
const { toast } = useToast(); const { toast } = useToast();
// Carrega pareceres da API // Carrega pareceres da API
const loadOpinions = async () => { const loadOpinions = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await agentService.getOpinions({ const data = await agentService.getOpinions({
@@ -65,7 +67,24 @@ export const AgentView = () => {
per_page: itemsPerPage, per_page: itemsPerPage,
search: searchTerm, search: searchTerm,
}); });
setOpinions(data);
// Adiciona status aos pareceres da API
const opinionsWithStatus = data.map(opinion => ({
...opinion,
status: (opinion.file_url || opinion.file_url_melhoria) ? 'concluido' : 'processando' as const,
}));
setOpinions(opinionsWithStatus);
// Remove registros temporários que agora estão na API
setPendingOpinions(prev =>
prev.filter(pending =>
!opinionsWithStatus.some(opinion =>
opinion.titulo === pending.titulo &&
opinion.created_at.substring(0, 10) === pending.created_at.substring(0, 10)
)
)
);
} catch (error: any) { } catch (error: any) {
console.error('Erro ao carregar pareceres:', error); console.error('Erro ao carregar pareceres:', error);
toast({ toast({
@@ -76,12 +95,23 @@ export const AgentView = () => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; }, [currentPage, itemsPerPage, searchTerm, toast]);
// Carrega pareceres ao montar o componente e quando mudar página/busca // Polling automático para atualizar status dos pareceres
useEffect(() => { useEffect(() => {
// Carrega imediatamente
loadOpinions(); loadOpinions();
}, [currentPage, itemsPerPage]);
// Configura polling a cada 10 segundos se houver pareceres pendentes
const interval = setInterval(() => {
if (pendingOpinions.length > 0) {
console.log('Polling: Atualizando lista de pareceres...');
loadOpinions();
}
}, 10000); // 10 segundos
return () => clearInterval(interval);
}, [currentPage, itemsPerPage, pendingOpinions.length, loadOpinions]);
// Debounce para busca // Debounce para busca
useEffect(() => { useEffect(() => {
@@ -94,7 +124,7 @@ export const AgentView = () => {
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm]); }, [searchTerm, loadOpinions]);
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
@@ -105,7 +135,10 @@ export const AgentView = () => {
} }
}; };
const sortedOpinions = [...opinions].sort((a, b) => { // Mescla pareceres da API com registros temporários
const allOpinions = [...pendingOpinions, ...opinions];
const sortedOpinions = [...allOpinions].sort((a, b) => {
const multiplier = sortOrder === "asc" ? 1 : -1; const multiplier = sortOrder === "asc" ? 1 : -1;
if (sortField === "created_at") { if (sortField === "created_at") {
@@ -119,8 +152,13 @@ export const AgentView = () => {
const totalPages = Math.ceil(sortedOpinions.length / itemsPerPage); const totalPages = Math.ceil(sortedOpinions.length / itemsPerPage);
// Callback quando um parecer está sendo criado (registro temporário)
const handleOpinionCreating = (tempOpinion: OpinionRecord) => {
setPendingOpinions(prev => [tempOpinion, ...prev]);
};
// Callback quando um parecer foi criado (recarrega da API)
const handleOpinionCreated = () => { const handleOpinionCreated = () => {
// Recarrega a lista após criar um novo parecer
loadOpinions(); loadOpinions();
}; };
@@ -284,6 +322,9 @@ export const AgentView = () => {
<ArrowUpDown className="w-4 h-4" /> <ArrowUpDown className="w-4 h-4" />
</Button> </Button>
</TableHead> </TableHead>
<TableHead className="hidden lg:table-cell text-center">
<span className="font-semibold text-xs md:text-sm">Status</span>
</TableHead>
<TableHead className="hidden sm:table-cell"> <TableHead className="hidden sm:table-cell">
<Button <Button
variant="ghost" variant="ghost"
@@ -300,13 +341,13 @@ export const AgentView = () => {
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-12 text-muted-foreground"> <TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
Carregando pareceres... Carregando pareceres...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : sortedOpinions.length === 0 ? ( ) : sortedOpinions.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-12 text-muted-foreground"> <TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
{searchTerm {searchTerm
? "Nenhum parecer encontrado" ? "Nenhum parecer encontrado"
: "Nenhum parecer criado ainda. Clique em 'Novo Parecer' para começar."} : "Nenhum parecer criado ainda. Clique em 'Novo Parecer' para começar."}
@@ -317,6 +358,26 @@ export const AgentView = () => {
<TableRow key={opinion.id}> <TableRow key={opinion.id}>
<TableCell className="font-medium text-xs md:text-sm">{opinion.titulo}</TableCell> <TableCell className="font-medium text-xs md:text-sm">{opinion.titulo}</TableCell>
<TableCell className="hidden md:table-cell text-sm">{opinion.categoria || "-"}</TableCell> <TableCell className="hidden md:table-cell text-sm">{opinion.categoria || "-"}</TableCell>
<TableCell className="hidden lg:table-cell text-center">
{opinion.status === 'processando' && (
<Badge variant="secondary" className="gap-1 bg-yellow-100 text-yellow-800 hover:bg-yellow-100">
<Loader2 className="w-3 h-3 animate-spin" />
Gerando...
</Badge>
)}
{opinion.status === 'concluido' && (
<Badge variant="secondary" className="gap-1 bg-green-100 text-green-800 hover:bg-green-100">
<CheckCircle2 className="w-3 h-3" />
Concluído
</Badge>
)}
{opinion.status === 'erro' && (
<Badge variant="destructive" className="gap-1">
<XCircle className="w-3 h-3" />
Erro
</Badge>
)}
</TableCell>
<TableCell className="hidden sm:table-cell text-xs md:text-sm"> <TableCell className="hidden sm:table-cell text-xs md:text-sm">
{new Date(opinion.created_at).toLocaleDateString("pt-BR")} {new Date(opinion.created_at).toLocaleDateString("pt-BR")}
</TableCell> </TableCell>
@@ -328,6 +389,7 @@ export const AgentView = () => {
onClick={() => handleViewOpinion(opinion)} onClick={() => handleViewOpinion(opinion)}
title="Visualizar" title="Visualizar"
className="h-7 w-7 md:h-9 md:w-9" className="h-7 w-7 md:h-9 md:w-9"
disabled={opinion.isLocalPending || opinion.status === 'processando'}
> >
<Eye className="w-3 h-3 md:w-4 md:h-4" /> <Eye className="w-3 h-3 md:w-4 md:h-4" />
</Button> </Button>
@@ -338,6 +400,7 @@ export const AgentView = () => {
size="icon" size="icon"
title="Baixar" title="Baixar"
className="h-7 w-7 md:h-9 md:w-9" className="h-7 w-7 md:h-9 md:w-9"
disabled={opinion.isLocalPending || opinion.status === 'processando'}
> >
<Download className="w-3 h-3 md:w-4 md:h-4" /> <Download className="w-3 h-3 md:w-4 md:h-4" />
</Button> </Button>
@@ -365,6 +428,7 @@ export const AgentView = () => {
onClick={() => setOpinionToDelete(opinion)} onClick={() => setOpinionToDelete(opinion)}
title="Excluir" title="Excluir"
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive" className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive"
disabled={opinion.isLocalPending}
> >
<Trash2 className="w-3 h-3 md:w-4 md:h-4" /> <Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button> </Button>
@@ -438,6 +502,7 @@ export const AgentView = () => {
onOpenChange={setIsDialogOpen} onOpenChange={setIsDialogOpen}
selectedOpinion={selectedOpinion} selectedOpinion={selectedOpinion}
onOpinionCreated={handleOpinionCreated} onOpinionCreated={handleOpinionCreated}
onOpinionCreating={handleOpinionCreating}
/> />
<AlertDialog open={!!opinionToDelete} onOpenChange={(open) => !open && setOpinionToDelete(null)}> <AlertDialog open={!!opinionToDelete} onOpenChange={(open) => !open && setOpinionToDelete(null)}>
+59 -16
View File
@@ -13,6 +13,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import { agentService, OpinionRecord } from "@/services/agent"; import { agentService, OpinionRecord } from "@/services/agent";
interface OpinionDialogProps { interface OpinionDialogProps {
@@ -20,6 +21,7 @@ interface OpinionDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
selectedOpinion: OpinionRecord | null; selectedOpinion: OpinionRecord | null;
onOpinionCreated: () => void; onOpinionCreated: () => void;
onOpinionCreating?: (tempOpinion: OpinionRecord) => void; // Callback para adicionar registro temporário
} }
export const OpinionDialog = ({ export const OpinionDialog = ({
@@ -27,6 +29,7 @@ export const OpinionDialog = ({
onOpenChange, onOpenChange,
selectedOpinion, selectedOpinion,
onOpinionCreated, onOpinionCreated,
onOpinionCreating,
}: OpinionDialogProps) => { }: OpinionDialogProps) => {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [category, setCategory] = useState(""); const [category, setCategory] = useState("");
@@ -68,25 +71,35 @@ export const OpinionDialog = ({
return; return;
} }
setIsGenerating(true); // Criar registro temporário para adicionar na tabela imediatamente
const tempId = `temp-${Date.now()}`;
try { const tempOpinion: OpinionRecord = {
const response = await agentService.createOpinion({ id: tempId,
estabelecimento_id: 0,
user_email: '',
titulo: title, titulo: title,
categoria: category, categoria: category,
instrucoes: instructions, instrucoes: instructions,
file_url: '',
file_url_melhoria: '',
created_at: new Date().toISOString(),
status: 'processando',
isLocalPending: true,
};
// Adiciona o registro temporário na tabela
if (onOpinionCreating) {
onOpinionCreating(tempOpinion);
}
// Criar toast de progresso que não fecha automaticamente
const loadingToast = toast({
title: "Gerando parecer jurídico...",
description: "O parecer está sendo processado. Isso pode levar alguns minutos.",
duration: Infinity, // Toast não fecha automaticamente
}); });
if (response.success) { // Fecha o dialog imediatamente
toast({
title: "Parecer gerado com sucesso!",
description: "O parecer foi criado e está disponível para download.",
});
// Chama o callback para atualizar a lista
onOpinionCreated();
// Fecha o dialog após sucesso
setIsGenerating(false); setIsGenerating(false);
onOpenChange(false); onOpenChange(false);
@@ -95,19 +108,49 @@ export const OpinionDialog = ({
setCategory(""); setCategory("");
setInstructions(""); setInstructions("");
setCreatedOpinion(null); setCreatedOpinion(null);
// Executar requisição em background
try {
const response = await agentService.createOpinion({
titulo: tempOpinion.titulo,
categoria: tempOpinion.categoria,
instrucoes: tempOpinion.instrucoes,
});
if (response.success) {
// Recarrega a lista para pegar o parecer real da API
onOpinionCreated();
// Atualizar toast para sucesso
loadingToast.update({
id: loadingToast.id,
title: "Parecer gerado com sucesso!",
description: "O parecer foi criado e está disponível para download.",
duration: 8000, // Toast fecha após 8 segundos
action: (
<ToastAction altText="Fechar notificação" onClick={() => loadingToast.dismiss()}>
OK
</ToastAction>
),
});
} else { } else {
throw new Error('Falha ao criar parecer'); throw new Error('Falha ao criar parecer');
} }
} catch (error: any) { } catch (error: any) {
console.error("Erro ao gerar parecer:", error); console.error("Erro ao gerar parecer:", error);
setIsGenerating(false);
const errorMessage = error.message || "Não foi possível gerar o parecer. Tente novamente."; const errorMessage = error.message || "Não foi possível gerar o parecer. Tente novamente.";
toast({ // Recarrega a lista para remover o registro temporário e tentar pegar dados reais
onOpinionCreated();
// Atualizar toast para erro
loadingToast.update({
id: loadingToast.id,
title: "Erro ao gerar parecer", title: "Erro ao gerar parecer",
description: errorMessage, description: errorMessage,
variant: "destructive", variant: "destructive",
duration: 10000, // Toast de erro fecha após 10 segundos
}); });
} }
}; };
+534
View File
@@ -0,0 +1,534 @@
import { useState, useMemo, useEffect } from "react";
import { Layers, Plus, Edit, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import { areasService, type AreaItem } from "@/services/areas";
import { toast } from "sonner";
export function AreasView() {
const { areas, setAreas, areaDescriptions, setAreaDescriptions, setPrompts } = usePrompts();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [selectedAreaItem, setSelectedAreaItem] = useState<AreaItem | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [isCreatingArea, setIsCreatingArea] = useState(false);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isDeletingArea, setIsDeletingArea] = useState(false);
const selectedArea = selectedAreaItem?.nome ?? null;
const [filterNome, setFilterNome] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [areasList, setAreasList] = useState<AreaItem[]>([]);
const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1);
const [loadingList, setLoadingList] = useState(true);
const normalizedAreas = useMemo(
() => new Set(areas.map((area) => area.trim().toLocaleLowerCase())),
[areas]
);
const totalPages = Math.max(1, totalPaginas);
const isFiltering = filterNome.trim().length > 0;
const createName = newName.trim();
const isCreateNameDuplicated = createName.length > 0 && normalizedAreas.has(createName.toLocaleLowerCase());
const editNameNormalized = editName.trim().toLocaleLowerCase();
const selectedAreaNormalized = selectedAreaItem?.nome.trim().toLocaleLowerCase();
const isEditNameDuplicated =
editName.trim().length > 0 &&
editNameNormalized !== selectedAreaNormalized &&
normalizedAreas.has(editNameNormalized);
useEffect(() => {
let cancelled = false;
setLoadingList(true);
areasService.listar({
nome: filterNome.trim() || undefined,
page: currentPage,
per_page: itemsPerPage,
})
.then((res) => {
if (!cancelled) {
setAreasList(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setAreas((prev) => [...new Set([...prev, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((prev) => ({
...prev,
...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])),
}));
}
})
.catch((e: unknown) => {
if (!cancelled) {
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao carregar áreas.";
toast.error(msg);
setAreasList([]);
setTotalRegistros(0);
setTotalPaginas(1);
}
})
.finally(() => {
if (!cancelled) setLoadingList(false);
});
return () => { cancelled = true; };
}, [filterNome, currentPage, itemsPerPage]);
useEffect(() => {
setCurrentPage(1);
}, [filterNome]);
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
const handleCreate = async () => {
const name = newName.trim();
const description = newDescription.trim();
if (!name) return;
if (normalizedAreas.has(name.toLocaleLowerCase())) {
toast.error("Já existe uma área com esse nome.");
return;
}
setIsCreatingArea(true);
try {
await areasService.criar(name, description);
setAreas((prev) => [...prev, name].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((prev) => ({ ...prev, [name]: description }));
setNewName("");
setNewDescription("");
setIsCreateOpen(false);
toast.success("Área criada.");
areasService.listar({ nome: filterNome.trim() || undefined, page: currentPage, per_page: itemsPerPage })
.then((res) => {
setAreasList(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setAreas((p) => [...new Set([...p, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((p) => ({ ...p, ...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])) }));
})
.catch(() => {});
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao criar área.";
toast.error(message);
} finally {
setIsCreatingArea(false);
}
};
const openEdit = (item: AreaItem) => {
setSelectedAreaItem(item);
setEditName(item.nome);
setEditDescription(item.descricao ?? areaDescriptions[item.nome] ?? "");
setIsEditOpen(true);
};
const refetchList = () => {
areasService.listar({ nome: filterNome.trim() || undefined, page: currentPage, per_page: itemsPerPage })
.then((res) => {
setAreasList(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setAreas((p) => [...new Set([...p, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((p) => ({ ...p, ...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])) }));
})
.catch(() => {});
};
const handleSaveEdit = async () => {
if (!selectedAreaItem || !editName.trim()) {
setIsEditOpen(false);
return;
}
const name = editName.trim();
const description = editDescription.trim();
if (
name.toLocaleLowerCase() !== selectedAreaItem.nome.trim().toLocaleLowerCase() &&
normalizedAreas.has(name.toLocaleLowerCase())
) {
toast.error("Já existe uma área com esse nome.");
return;
}
setIsSavingEdit(true);
try {
await areasService.editar(selectedAreaItem.id, name, description);
const oldName = selectedAreaItem.nome;
if (name !== oldName) {
setAreas((prev) => prev.map((a) => (a === oldName ? name : a)).sort((a, b) => a.localeCompare(b)));
setPrompts((prev) => prev.map((p) => (p.area === oldName ? { ...p, area: name } : p)));
}
setAreaDescriptions((prev) => {
const next = { ...prev };
if (oldName in next) delete next[oldName];
next[name] = description;
return next;
});
setSelectedAreaItem(null);
setIsEditOpen(false);
toast.success("Área atualizada.");
refetchList();
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao editar área.";
toast.error(message);
} finally {
setIsSavingEdit(false);
}
};
const openDelete = (item: AreaItem) => {
setSelectedAreaItem(item);
setIsDeleteOpen(true);
};
const handleDelete = async () => {
if (!selectedAreaItem || totalRegistros <= 1) return;
setIsDeletingArea(true);
try {
await areasService.deletar(selectedAreaItem.id);
const fallback = areasList.find((a) => a.id !== selectedAreaItem!.id)?.nome ?? areas.find((a) => a !== selectedAreaItem!.nome);
if (fallback) {
setPrompts((prev) =>
prev.map((p) => (p.area === selectedAreaItem.nome ? { ...p, area: fallback } : p))
);
}
setAreas((prev) => prev.filter((a) => a !== selectedAreaItem.nome));
setAreaDescriptions((prev) => {
const next = { ...prev };
delete next[selectedAreaItem.nome];
return next;
});
setSelectedAreaItem(null);
setIsDeleteOpen(false);
toast.success("Área excluída.");
refetchList();
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao excluir área.";
toast.error(message);
} finally {
setIsDeletingArea(false);
}
};
const clearFilters = () => {
setFilterNome("");
setCurrentPage(1);
};
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<Layers className="w-5 h-5 md:w-6 md:h-6" />
Áreas
</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => setIsCreateOpen(true)} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Nova área</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
{(loadingList || totalRegistros > 0) && (
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
<Input
placeholder="Buscar áreas..."
value={filterNome}
onChange={(e) => setFilterNome(e.target.value)}
className="w-full md:max-w-sm text-sm"
/>
{filterNome.trim() && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Limpar
</Button>
)}
<div className="flex items-center gap-2 justify-between md:justify-start">
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loadingList && totalRegistros === 0 && !isFiltering ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="w-5 h-5" />
Nenhuma área cadastrada
</CardTitle>
<CardDescription>
Crie a primeira área para usar nos prompts (ex.: Bate-papo, Imagens).
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setIsCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Nova área
</Button>
</CardContent>
</Card>
) : (
<>
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Área</TableHead>
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground py-8">
Carregando...
</TableCell>
</TableRow>
) : totalRegistros === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground py-8">
Nenhum resultado para o filtro {filterNome.trim() ? `"${filterNome.trim()}"` : "atual"}.
</TableCell>
</TableRow>
) : (
areasList.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-xs md:text-sm">{item.nome}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(item)}
title="Editar"
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-foreground"
>
<Edit className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openDelete(item)}
title="Excluir"
disabled={totalRegistros <= 1}
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-100 dark:hover:bg-slate-700 text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loadingList && totalRegistros > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {totalRegistros} {totalRegistros === 1 ? "área" : "áreas"}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page: number;
if (totalPages <= 5) {
page = i + 1;
} else if (currentPage <= 3) {
page = i + 1;
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i;
} else {
page = currentPage - 2 + i;
}
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)}
</>
)}
</div>
{/* Modal Novo */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nova área</DialogTitle>
<DialogDescription>Informe o nome e, se quiser, a descrição da área. Ela poderá ser usada nos prompts.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="new-area">Nome</Label>
<Input
id="new-area"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Ex: Suporte"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
aria-invalid={isCreateNameDuplicated}
className={isCreateNameDuplicated ? "border-destructive focus-visible:ring-destructive" : undefined}
/>
{isCreateNameDuplicated && (
<p className="mt-1 text-sm text-destructive">
existe uma área com esse nome. Escolha um nome diferente.
</p>
)}
</div>
<div>
<Label htmlFor="new-area-desc">Descrição</Label>
<Textarea
id="new-area-desc"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Ex: Área para prompts de atendimento"
rows={3}
className="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreatingArea}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!newName.trim() || isCreateNameDuplicated || isCreatingArea}>
{isCreatingArea ? "Criando..." : "Criar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal Editar */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar área</DialogTitle>
<DialogDescription>Altere o nome e a descrição da área. Os prompts vinculados serão atualizados.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="edit-area">Nome</Label>
<Input
id="edit-area"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSaveEdit()}
aria-invalid={isEditNameDuplicated}
className={isEditNameDuplicated ? "border-destructive focus-visible:ring-destructive" : undefined}
/>
{isEditNameDuplicated && (
<p className="mt-1 text-sm text-destructive">
existe uma área com esse nome. Escolha um nome diferente.
</p>
)}
</div>
<div>
<Label htmlFor="edit-area-desc">Descrição</Label>
<Textarea
id="edit-area-desc"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Ex: Área para prompts de atendimento"
rows={3}
className="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isSavingEdit}>
Cancelar
</Button>
<Button onClick={handleSaveEdit} disabled={!editName.trim() || isEditNameDuplicated || isSavingEdit}>
{isSavingEdit ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal Excluir */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir área</AlertDialogTitle>
<AlertDialogDescription>
Deseja excluir a área "{selectedArea}"? Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingArea} className="bg-white text-slate-900 hover:bg-slate-100 hover:text-slate-900">
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={totalRegistros <= 1 || isDeletingArea}
className={(totalRegistros <= 1 || isDeletingArea) ? "opacity-50 cursor-not-allowed" : "bg-destructive text-destructive-foreground hover:bg-destructive/90"}
>
{isDeletingArea ? "Excluindo..." : "Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+18 -11
View File
@@ -14,6 +14,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { audioGenerationService } from "@/services/audioGeneration";
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']; const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
@@ -186,19 +187,23 @@ export const AudioView = () => {
}, 2000); }, 2000);
}; };
const handleDownloadAudio = (audio: GeneratedAudio) => { const handleDownloadAudio = async (audio: GeneratedAudio) => {
// In real implementation, download the actual audio file const filename = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
const a = document.createElement('a'); try {
a.href = audio.audioUrl; await audioGenerationService.downloadAudioFile(audio.audioUrl, filename);
a.download = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast({ toast({
title: "Download iniciado", title: "Download iniciado",
description: "O arquivo de áudio está sendo baixado.", description: "O arquivo de áudio está sendo baixado.",
}); });
} catch (error: unknown) {
const message =
error && typeof error === "object" && "message" in error ? String((error as { message: string }).message) : "Não foi possível baixar o áudio.";
toast({
title: "Erro ao baixar",
description: message,
variant: "destructive",
});
}
}; };
const handleDeleteAudio = (audioId: string) => { const handleDeleteAudio = (audioId: string) => {
@@ -482,10 +487,11 @@ export const AudioView = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-1" className="gap-1"
onClick={() => handleDownloadAudio(generatedAudio)} onClick={() => void handleDownloadAudio(generatedAudio)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
Baixar MP3 Baixar MP3
@@ -658,9 +664,10 @@ export const AudioView = () => {
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleDownloadAudio(item)} onClick={() => void handleDownloadAudio(item)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
</Button> </Button>
+17 -10
View File
@@ -134,18 +134,23 @@ export const GenerationView = () => {
} }
}; };
const handleDownloadAudio = (audio: AudioRecord) => { const handleDownloadAudio = async (audio: AudioRecord) => {
const a = document.createElement('a'); const filename = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
a.href = audio.audio_url; try {
a.download = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`; await audioGenerationService.downloadAudioFile(audio.audio_url, filename);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast({ toast({
title: "Download iniciado", title: "Download iniciado",
description: "O arquivo de áudio está sendo baixado.", description: "O arquivo de áudio está sendo baixado.",
}); });
} catch (error: unknown) {
const message =
error && typeof error === "object" && "message" in error ? String((error as { message: string }).message) : "Não foi possível baixar o áudio.";
toast({
title: "Erro ao baixar",
description: message,
variant: "destructive",
});
}
}; };
const handleDeleteAudio = async (audioId: string) => { const handleDeleteAudio = async (audioId: string) => {
@@ -290,10 +295,11 @@ export const GenerationView = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-1" className="gap-1"
onClick={() => handleDownloadAudio(lastGeneratedAudio)} onClick={() => void handleDownloadAudio(lastGeneratedAudio)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
Baixar Baixar
@@ -385,9 +391,10 @@ export const GenerationView = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => handleDownloadAudio(audio)} onClick={() => void handleDownloadAudio(audio)}
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
+11 -6
View File
@@ -1,4 +1,5 @@
import { Bot, User, Copy, File } from "lucide-react"; import { Bot, User, Copy, File } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useState } from "react"; import { useState } from "react";
@@ -53,7 +54,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
</div> </div>
)} )}
<div className={`flex-1 max-w-3xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}> <div className={`flex-1 max-w-4xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
{/* Attachments */} {/* Attachments */}
{attachments && attachments.length > 0 && ( {attachments && attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2 mb-2">
@@ -73,15 +74,19 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
)} )}
<div <div
className={`inline-block px-3 md:px-4 py-2 md:py-3 rounded-xl ${ className={`inline-block max-w-[90%] px-3 md:px-4 py-2 md:py-3 rounded-xl break-words ${
isAssistant isAssistant
? "bg-card border border-border" ? "bg-card border border-border text-foreground"
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground" : "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
}`} }`}
> >
<p className={`text-sm md:text-base leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}> {isAssistant ? (
{content} <div className="prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-p:leading-relaxed">
</p> <ReactMarkdown>{content}</ReactMarkdown>
</div>
) : (
<p className="text-sm md:text-base leading-relaxed text-white">{content}</p>
)}
</div> </div>
{showActions && ( {showActions && (
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { ParecerJuridicoGeneratingScreen } from "@/components/parecer-juridico/ParecerJuridicoGeneratingScreen";
import { areasService, type AreaItem } from "@/services/areas";
import { promptsService, type PromptItem } from "@/services/promptsApi";
import { parecerService } from "@/services/parecerApi";
import { playSuccessSound } from "@/utils/sound";
import { toast } from "sonner";
export function ParecerJuridicoFormView() {
const navigate = useNavigate();
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
const [areasLoading, setAreasLoading] = useState(true);
const [areasError, setAreasError] = useState(false);
const [tituloParecer, setTituloParecer] = useState("");
const [areaId, setAreaId] = useState("");
const [promptId, setPromptId] = useState("");
const [conteudoPrompt, setConteudoPrompt] = useState("");
const [instrucao, setInstrucao] = useState("");
const [anexo, setAnexo] = useState<File | null>(null);
const anexoInputRef = useRef<HTMLInputElement>(null);
const [promptsDaArea, setPromptsDaArea] = useState<PromptItem[]>([]);
const [loadingPrompts, setLoadingPrompts] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
setAreasLoading(true);
setAreasError(false);
areasService.listarTotal()
.then((data) => setAreaItems(data))
.catch(() => setAreasError(true))
.finally(() => setAreasLoading(false));
}, []);
const ANEXO_ACCEPT = ".pdf,.txt";
const handleAnexoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
setAnexo(null);
return;
}
const ext = file.name.split(".").pop()?.toLowerCase();
const allowed = ["pdf", "txt"];
if (!ext || !allowed.includes(ext)) {
toast.error("Formato não permitido. Use PDF ou TXT.");
e.target.value = "";
return;
}
setAnexo(file);
};
const removerAnexo = () => {
setAnexo(null);
if (anexoInputRef.current) anexoInputRef.current.value = "";
};
useEffect(() => {
if (!areaId.trim()) {
setPromptsDaArea([]);
setPromptId("");
setConteudoPrompt("");
return;
}
setPromptId("");
setConteudoPrompt("");
setLoadingPrompts(true);
promptsService.listarPorArea(areaId)
.then((res) => setPromptsDaArea(res.data ?? []))
.catch(() => {
toast.error("Erro ao carregar prompts da área.");
setPromptsDaArea([]);
})
.finally(() => setLoadingPrompts(false));
}, [areaId]);
useEffect(() => {
if (!promptId.trim() || promptsDaArea.length === 0) return;
const prompt = promptsDaArea.find((p) => p.id === promptId);
if (prompt) setConteudoPrompt(prompt.conteudo ?? "");
}, [promptId, promptsDaArea]);
const canSubmit =
!isGenerating &&
tituloParecer.trim().length > 0 &&
areaId.trim().length > 0 &&
conteudoPrompt.trim().length > 0;
const handleGerarParecer = () => {
const titulo = tituloParecer.trim();
const area = areaId.trim();
const prompt = conteudoPrompt.trim();
const instr = instrucao.trim();
if (!titulo || !area || !prompt) {
toast.error("Preencha os campos obrigatórios antes de gerar.");
return;
}
setIsGenerating(true);
parecerService.gerar({
titulo,
area_id: area,
prompt_id: promptId.trim() || undefined,
prompt,
instrucao: instr || undefined,
anexo: anexo ?? undefined,
})
.then((data) => {
setIsGenerating(false);
playSuccessSound();
navigate(`/codex/parecer-juridico/${data.id}`, {
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
});
})
.catch((err) => {
setIsGenerating(false);
toast.error(err?.message ?? "Erro ao gerar parecer.");
});
};
const descricaoPromptSelecionado = promptId ? promptsDaArea.find((p) => p.id === promptId)?.descricao?.trim() : "";
return (
<>
{isGenerating && (
<ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." />
)}
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0">
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-5 h-5 md:w-6 md:h-6" />
Novo Parecer
</h1>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
<div className="max-w-4xl mx-auto flex flex-col gap-6">
<div className="space-y-2">
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
<span>Título do parecer</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Input
id="titulo-parecer"
value={tituloParecer}
onChange={(e) => setTituloParecer(e.target.value)}
placeholder="Ex.: Parecer sobre contrato de prestação de serviços"
className="w-full"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label htmlFor="area" className="flex flex-wrap items-baseline gap-1">
<span>Área</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Select value={areaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
<SelectTrigger id="area" className="w-full">
<SelectValue
placeholder={
areasLoading ? "Carregando áreas..." : areasError ? "Erro ao carregar áreas" : "Selecione a área"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Selecione a área</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
{areasError && (
<p className="text-sm text-destructive">Não foi possível carregar as áreas. Verifique sua conexão e tente novamente.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="prompt">Prompt da base</Label>
<Select
value={promptId || "none"}
onValueChange={(v) => setPromptId(v === "none" ? "" : v)}
disabled={!areaId.trim() || loadingPrompts}
>
<SelectTrigger id="prompt" className="w-full">
<SelectValue
placeholder={
loadingPrompts ? "Carregando..." : promptsDaArea.length === 0 && areaId.trim() ? "Nenhum prompt" : "Selecione o prompt"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Selecione o prompt</SelectItem>
{promptsDaArea.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.titulo}
</SelectItem>
))}
</SelectContent>
</Select>
{areaId.trim() && !loadingPrompts && promptsDaArea.length === 0 && (
<p className="text-sm text-muted-foreground">Não prompts para essa área.</p>
)}
{descricaoPromptSelecionado && (
<p className="text-sm text-muted-foreground mt-1">{descricaoPromptSelecionado}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="conteudo-prompt" className="flex flex-wrap items-baseline gap-1">
<span>Conteúdo do prompt</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Textarea
id="conteudo-prompt"
value={conteudoPrompt}
onChange={(e) => setConteudoPrompt(e.target.value)}
placeholder="Selecione um prompt acima ou edite o conteúdo aqui."
className="min-h-[200px] resize-y w-full"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label htmlFor="instrucao">Instrução</Label>
<Textarea
id="instrucao"
value={instrucao}
onChange={(e) => setInstrucao(e.target.value)}
placeholder="Instruções adicionais para a geração do parecer (opcional)..."
className="min-h-[100px] resize-y w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="anexo">Anexo</Label>
<p className="text-xs text-muted-foreground">Formatos permitidos: PDF, TXT.</p>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2 w-full min-h-10 rounded-md border border-input bg-background px-3 py-2">
<input
ref={anexoInputRef}
id="anexo"
type="file"
accept={ANEXO_ACCEPT}
onChange={handleAnexoChange}
className="sr-only"
/>
<Label
htmlFor="anexo"
className="cursor-pointer shrink-0 rounded-md border-0 bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Escolher arquivo
</Label>
<span className="text-sm text-muted-foreground truncate flex-1 min-w-0">
{anexo ? anexo.name : "Nenhum arquivo escolhido"}
</span>
{anexo && (
<Button type="button" variant="ghost" size="icon" onClick={removerAnexo} className="h-8 w-8 shrink-0" title="Remover anexo">
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
<div className="flex justify-end pt-2">
<InteractiveHoverButton
type="button"
onClick={handleGerarParecer}
disabled={!canSubmit}
className="disabled:opacity-50 disabled:pointer-events-none"
>
Gerar Parecer
</InteractiveHoverButton>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,256 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import {
Bot,
Sparkles,
FileText,
Globe,
BookOpen,
Database,
} from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Terminal, AnimatedStep } from "@/components/ui/terminal";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { AnimatedBeam } from "@/components/ui/animated-beam";
import { cn } from "@/lib/utils";
const GERAR_PARECER_STEPS = [
"Pesquisando fontes jurídicas confiáveis...",
"Analisando documentação e anexos...",
"Consultando jurisprudência e decisões correlatas...",
"Verificando legislação vigente e súmulas...",
"Identificando fundamentação legal aplicável...",
"Estruturando argumentação...",
"Elaborando conclusões...",
"Revisando citações e referências...",
"Consolidando fundamentos e redigindo parecer...",
"Estruturando parecer...",
] as const;
const STEP_COUNT = GERAR_PARECER_STEPS.length;
const STEP_INTERVAL_MS = 1500;
export interface ParecerJuridicoGeneratingScreenProps {
progress?: number;
message?: string;
}
function visibleLineCountFromProgress(progress: number): number {
if (progress <= 0) return 1;
const phase = (progress / 100) * STEP_COUNT;
const stepInCycle = Math.floor(phase) % STEP_COUNT;
return Math.min(stepInCycle + 1, STEP_COUNT);
}
const BeamCircle = forwardRef<
HTMLDivElement,
{ className?: string; children?: React.ReactNode }
>(({ className, children }, ref) => (
<div
ref={ref}
className={cn(
"z-10 flex size-12 items-center justify-center rounded-full border-2 border-slate-300 bg-slate-100 text-slate-700 shadow-sm",
className
)}
>
{children}
</div>
));
BeamCircle.displayName = "BeamCircle";
function GoogleIcon({ className }: { className?: string }) {
return (
<svg
className={cn("size-6", className)}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}
export function ParecerJuridicoGeneratingScreen({
progress,
message = "Assim que finalizar, o modal será fechado automaticamente.",
}: ParecerJuridicoGeneratingScreenProps) {
const containerRef = useRef<HTMLDivElement>(null);
const sparklesRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLDivElement>(null);
const globeRef = useRef<HTMLDivElement>(null);
const centerRef = useRef<HTMLDivElement>(null);
const googleRef = useRef<HTMLDivElement>(null);
const bookRef = useRef<HTMLDivElement>(null);
const dbRef = useRef<HTMLDivElement>(null);
const [loopStep, setLoopStep] = useState(0);
useEffect(() => {
if (progress !== undefined) return;
const id = setInterval(() => {
setLoopStep((s) => (s + 1) % STEP_COUNT);
}, STEP_INTERVAL_MS);
return () => clearInterval(id);
}, [progress]);
const visibleCount =
progress !== undefined
? visibleLineCountFromProgress(progress)
: loopStep + 1;
return (
<Dialog open>
<DialogContent
hideClose
overlayClassName="bg-black/50"
className="max-w-2xl gap-0 overflow-hidden bg-white p-0 sm:rounded-xl"
>
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3 rounded-t-lg border border-b-0 border-border bg-zinc-100 px-3 py-2.5">
<div className="flex gap-1.5">
<span className="size-3 rounded-full bg-[#ff5f57]" aria-hidden />
<span className="size-3 rounded-full bg-[#febc2e]" aria-hidden />
<span className="size-3 rounded-full bg-[#28c840]" aria-hidden />
</div>
<span className="font-mono text-xs text-muted-foreground">
codex parecer gerando
</span>
</div>
<Terminal className="min-h-[200px] space-y-2 rounded-t-none border-t-0 bg-white p-4 font-mono text-sm">
<AnimatedStep delay={0} className="text-muted-foreground">
&gt; codex parecer --generate
</AnimatedStep>
{GERAR_PARECER_STEPS.slice(0, visibleCount).map((text, i) => (
<div key={`${visibleCount}-${i}-${text}`} className="flex items-center gap-0">
{i < visibleCount - 1 ? (
<span className="text-green-600 dark:text-green-400">
{text}
</span>
) : (
<TypingAnimation
key={`typing-${visibleCount}`}
startOnView={false}
showCursor
cursorStyle="block"
className="text-green-600 dark:text-green-400"
>
{`${text}`}
</TypingAnimation>
)}
</div>
))}
</Terminal>
<p className="text-center text-sm text-muted-foreground">
{message ?? "A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar."}
</p>
<div className="rounded-xl border border-border bg-white p-6">
<div
ref={containerRef}
className="relative flex h-[260px] w-full items-center justify-center overflow-hidden"
>
<div className="flex size-full max-h-[220px] max-w-lg flex-col items-stretch justify-between">
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={sparklesRef}>
<Sparkles className="size-6 text-amber-400" />
</BeamCircle>
<BeamCircle ref={fileRef}>
<FileText className="size-6 text-slate-600" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={globeRef}>
<Globe className="size-6 text-sky-400" />
</BeamCircle>
<BeamCircle ref={centerRef} className="size-14 border-slate-400 bg-slate-200">
<Bot className="size-8 text-slate-700" />
</BeamCircle>
<BeamCircle ref={googleRef}>
<GoogleIcon className="size-6" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={bookRef}>
<BookOpen className="size-6 text-slate-600" />
</BeamCircle>
<BeamCircle ref={dbRef}>
<Database className="size-6 text-slate-600" />
</BeamCircle>
</div>
</div>
<AnimatedBeam
containerRef={containerRef}
fromRef={sparklesRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
gradientStartColor="#f59e0b"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={fileRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={globeRef}
toRef={centerRef}
gradientStartColor="#0ea5e9"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={googleRef}
toRef={centerRef}
reverse
gradientStartColor="#64748b"
gradientStopColor="#94a3b8"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={bookRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={dbRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,316 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, Edit, Plus, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import { parecerService, type ParecerListItem, type ParecerStatus } from "@/services/parecerApi";
import { toast } from "sonner";
const STATUS_OPTIONS: { value: "" | ParecerStatus; label: string }[] = [
{ value: "", label: "Todos os status" },
{ value: "processando", label: "Processando" },
{ value: "concluido", label: "Concluído" },
{ value: "erro", label: "Erro" },
];
function formatarData(createdAt: string): string {
if (!createdAt) return "—";
try {
const d = new Date(createdAt.replace(" ", "T"));
return d.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return createdAt;
}
}
function statusEstilo(status: string): { dot: string; text: string; label: string } {
const s = (status ?? "").toLowerCase();
if (s === "concluido") return { dot: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400", label: "Concluído" };
if (s === "processando") return { dot: "bg-amber-500", text: "text-amber-600 dark:text-amber-400", label: "Processando" };
if (s === "erro") return { dot: "bg-red-500", text: "text-red-600 dark:text-red-400", label: "Erro" };
return { dot: "bg-muted-foreground/50", text: "text-muted-foreground", label: status || "—" };
}
export const ParecerJuridicoView = () => {
const navigate = useNavigate();
const { areaItems, refreshAreas } = usePrompts();
const [data, setData] = useState<ParecerListItem[]>([]);
const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1);
const [paginaAtual, setPaginaAtual] = useState(1);
const [perPage, setPerPage] = useState(10);
const [loading, setLoading] = useState(true);
const [filterTitulo, setFilterTitulo] = useState("");
const [filterAreaId, setFilterAreaId] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<"" | ParecerStatus>("");
const hasActiveFilters = filterTitulo.trim() !== "" || filterAreaId !== "" || filterStatus !== "";
const clearFilters = () => {
setFilterTitulo("");
setFilterAreaId("");
setFilterStatus("");
setPaginaAtual(1);
};
const loadList = useCallback(() => {
setLoading(true);
parecerService
.listar({
titulo: filterTitulo.trim() || undefined,
area_id: filterAreaId.trim() || undefined,
status: filterStatus || undefined,
page: paginaAtual,
per_page: perPage,
})
.then((res) => {
setData(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(Math.max(1, res.total_paginas ?? 1));
setPaginaAtual(res.pagina_atual ?? 1);
setPerPage(res.per_page ?? 10);
})
.catch((err) => {
toast.error(err?.message ?? "Erro ao carregar pareceres.");
setData([]);
setTotalRegistros(0);
setTotalPaginas(1);
})
.finally(() => setLoading(false));
}, [paginaAtual, perPage, filterTitulo, filterAreaId, filterStatus]);
useEffect(() => {
loadList();
}, [loadList]);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
const handleItemsPerPageChange = (value: string) => {
setPerPage(Number(value));
setPaginaAtual(1);
};
const handleEditar = (item: ParecerListItem) => {
navigate(`/codex/parecer-juridico/${item.id}`);
};
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<Bot className="w-5 h-5 md:w-6 md:h-6" />
Parecer Jurídico
</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => navigate("/codex/parecer-juridico/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo Parecer</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4 flex-wrap">
<Input
placeholder="Buscar por título..."
value={filterTitulo}
onChange={(e) => { setFilterTitulo(e.target.value); setPaginaAtual(1); }}
className="w-full md:max-w-sm text-sm"
/>
<Select value={filterAreaId || "all"} onValueChange={(v) => { setFilterAreaId(v === "all" ? "" : v); setPaginaAtual(1); }}>
<SelectTrigger className="w-full md:w-[180px] text-sm">
<SelectValue placeholder="Todas as áreas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as áreas</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filterStatus || "all"}
onValueChange={(v) => { setFilterStatus((v === "all" ? "" : v) as "" | ParecerStatus); setPaginaAtual(1); }}
>
<SelectTrigger className="w-full md:w-[160px] text-sm">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value || "all"} value={opt.value || "all"}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Limpar
</Button>
)}
<div className="flex items-center gap-2 justify-between md:justify-start">
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens por página:</span>
<Select value={perPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{loading ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardContent className="py-10 text-center text-muted-foreground">
Carregando pareceres...
</CardContent>
</Card>
) : data.length === 0 && !hasActiveFilters ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
Nenhum parecer encontrado
</CardTitle>
<CardDescription>
Os pareceres jurídicos gerados aparecerão aqui quando disponíveis.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
) : (
<>
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[100px] font-semibold text-xs md:text-sm">Data</TableHead>
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Status</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Criado por</TableHead>
<TableHead className="text-center font-semibold text-xs md:text-sm w-[80px]">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
Nenhum resultado para os filtros aplicados.
</TableCell>
</TableRow>
) : (
data.map((item) => {
const status = statusEstilo(item.status);
return (
<TableRow key={item.id}>
<TableCell className="text-xs md:text-sm">{formatarData(item.created_at)}</TableCell>
<TableCell className="font-medium text-xs md:text-sm">{item.titulo}</TableCell>
<TableCell className="text-xs md:text-sm">{item.area_nome}</TableCell>
<TableCell className="text-xs md:text-sm">
<span className={`inline-flex items-center gap-2 ${status.text}`}>
<span className={`size-2 rounded-full shrink-0 ${status.dot}`} aria-hidden />
{status.label}
</span>
</TableCell>
<TableCell className="text-xs md:text-sm">{item.criado_por ?? "—"}</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditar(item)}
title="Editar"
className="h-7 w-7 md:h-9 md:w-9"
>
<Edit className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
); })
)}
</TableBody>
</Table>
</div>
{totalRegistros > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {data.length} de {totalRegistros} {totalRegistros === 1 ? "parecer" : "pareceres"}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setPaginaAtual((p) => Math.max(1, p - 1))}
disabled={paginaAtual <= 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPaginas, 5) }, (_, i) => {
let page: number;
if (totalPaginas <= 5) page = i + 1;
else if (paginaAtual <= 3) page = i + 1;
else if (paginaAtual >= totalPaginas - 2) page = totalPaginas - 4 + i;
else page = paginaAtual - 2 + i;
return (
<Button
key={page}
variant={paginaAtual === page ? "default" : "outline"}
size="sm"
onClick={() => setPaginaAtual(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPaginaAtual((p) => Math.min(totalPaginas, p + 1))}
disabled={paginaAtual >= totalPaginas}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
@@ -0,0 +1,248 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import {
Bot,
Sparkles,
FileText,
Wand2,
SlidersHorizontal,
ListChecks,
MessageSquareText,
} from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Terminal, AnimatedStep } from "@/components/ui/terminal";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { AnimatedBeam } from "@/components/ui/animated-beam";
import { cn } from "@/lib/utils";
const PROMPT_CREATE_STEPS = [
"Interpretando instruções e contexto...",
"Definindo objetivo, público e tom...",
"Organizando estrutura do prompt (seções e regras)...",
"Especificando formato de saída e restrições...",
"Adicionando critérios de qualidade e validações...",
"Incluindo exemplos e casos de borda...",
"Revisando clareza e consistência...",
"Finalizando prompt...",
] as const;
const PROMPT_REFINE_STEPS = [
"Lendo o prompt atual...",
"Identificando ambiguidades e pontos fracos...",
"Removendo redundâncias e ruído...",
"Aprimorando instruções e critérios de sucesso...",
"Ajustando tom, voz e consistência...",
"Fortalecendo formato de saída e validações...",
"Adicionando exemplos e casos de borda...",
"Consolidando melhorias e finalizando...",
] as const;
const STEP_INTERVAL_MS = 1500;
export type PromptGeneratingMode = "create" | "refine";
export interface PromptGeneratingScreenProps {
mode: PromptGeneratingMode;
progress?: number;
message?: string;
}
function visibleLineCount(progress: number, stepCount: number): number {
if (progress <= 0) return 1;
const phase = (progress / 100) * stepCount;
const stepInCycle = Math.floor(phase) % stepCount;
return Math.min(stepInCycle + 1, stepCount);
}
const BeamCircle = forwardRef<
HTMLDivElement,
{ className?: string; children?: React.ReactNode }
>(({ className, children }, ref) => (
<div
ref={ref}
className={cn(
"z-10 flex size-12 items-center justify-center rounded-full border-2 border-slate-300 bg-slate-100 text-slate-700 shadow-sm",
className
)}
>
{children}
</div>
));
BeamCircle.displayName = "BeamCircle";
export function PromptGeneratingScreen({
mode,
progress,
message = "Assim que finalizar, o modal será fechado automaticamente.",
}: PromptGeneratingScreenProps) {
const steps = mode === "refine" ? PROMPT_REFINE_STEPS : PROMPT_CREATE_STEPS;
const stepCount = steps.length;
const containerRef = useRef<HTMLDivElement>(null);
const sparklesRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLDivElement>(null);
const slidersRef = useRef<HTMLDivElement>(null);
const centerRef = useRef<HTMLDivElement>(null);
const wandRef = useRef<HTMLDivElement>(null);
const checklistRef = useRef<HTMLDivElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
const [loopStep, setLoopStep] = useState(0);
useEffect(() => {
if (progress !== undefined) return;
const id = setInterval(() => {
setLoopStep((s) => (s + 1) % stepCount);
}, STEP_INTERVAL_MS);
return () => clearInterval(id);
}, [progress, stepCount]);
const visibleCount =
progress !== undefined
? visibleLineCount(progress, stepCount)
: loopStep + 1;
return (
<Dialog open>
<DialogContent
hideClose
overlayClassName="bg-black/50"
className="max-w-2xl gap-0 overflow-hidden bg-white p-0 sm:rounded-xl"
>
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3 rounded-t-lg border border-b-0 border-border bg-zinc-100 px-3 py-2.5">
<div className="flex gap-1.5">
<span className="size-3 rounded-full bg-[#ff5f57]" aria-hidden />
<span className="size-3 rounded-full bg-[#febc2e]" aria-hidden />
<span className="size-3 rounded-full bg-[#28c840]" aria-hidden />
</div>
<span className="font-mono text-xs text-muted-foreground">
codex prompt {mode === "refine" ? "melhorando" : "gerando"}
</span>
</div>
<Terminal className="min-h-[200px] space-y-2 rounded-t-none border-t-0 bg-white p-4 font-mono text-sm">
<AnimatedStep delay={0} className="text-muted-foreground">
{`> codex prompt --${mode === "refine" ? "refine" : "create"}`}
</AnimatedStep>
{steps.slice(0, visibleCount).map((text, i) => (
<div key={`${visibleCount}-${i}-${text}`} className="flex items-center gap-0">
{i < visibleCount - 1 ? (
<span className="text-sky-600 dark:text-sky-400">
{text}
</span>
) : (
<TypingAnimation
key={`typing-${visibleCount}`}
startOnView={false}
showCursor
cursorStyle="block"
className="text-sky-600 dark:text-sky-400"
>
{`${text}`}
</TypingAnimation>
)}
</div>
))}
</Terminal>
{message && (
<p className="text-center text-sm text-muted-foreground">
{message}
</p>
)}
<div className="rounded-xl border border-border bg-white p-6">
<div
ref={containerRef}
className="relative flex h-[260px] w-full items-center justify-center overflow-hidden"
>
<div className="flex size-full max-h-[220px] max-w-lg flex-col items-stretch justify-between">
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={sparklesRef}>
<Sparkles className="size-6 text-sky-500" />
</BeamCircle>
<BeamCircle ref={fileRef}>
<FileText className="size-6 text-slate-600" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={slidersRef}>
<SlidersHorizontal className="size-6 text-indigo-500" />
</BeamCircle>
<BeamCircle ref={centerRef} className="size-14 border-slate-400 bg-slate-200">
<Bot className="size-8 text-slate-700" />
</BeamCircle>
<BeamCircle ref={wandRef}>
<Wand2 className="size-6 text-amber-500" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={checklistRef}>
<ListChecks className="size-6 text-slate-600" />
</BeamCircle>
<BeamCircle ref={chatRef}>
<MessageSquareText className="size-6 text-slate-600" />
</BeamCircle>
</div>
</div>
<AnimatedBeam
containerRef={containerRef}
fromRef={sparklesRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
gradientStartColor="#0ea5e9"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={fileRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={slidersRef}
toRef={centerRef}
gradientStartColor="#6366f1"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={wandRef}
toRef={centerRef}
reverse
gradientStartColor="#f59e0b"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={checklistRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={chatRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+423
View File
@@ -0,0 +1,423 @@
import React, { useState, useEffect, useRef, forwardRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, ArrowRight, Sparkles, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ShimmerButton } from "@/components/ui/shimmer-button";
import { SparklesText } from "@/components/ui/sparkles-text";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { BorderBeam } from "@/components/ui/border-beam";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
import { cn } from "@/lib/utils";
import { usePrompts } from "@/contexts/PromptsContext";
import { areasService, type AreaItem } from "@/services/areas";
import { PromptGeneratingScreen } from "@/components/prompts/PromptGeneratingScreen";
import { assistentePromptsService } from "@/services/assistentePrompts";
import { promptsService } from "@/services/promptsApi";
import { toast } from "sonner";
import { playSuccessSound } from "@/utils/sound";
export function PromptsFormView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { prompts, setPrompts } = usePrompts();
const isEdit = Boolean(id);
const prompt = id ? prompts.find((p) => p.id === id) : null;
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
const [areasLoading, setAreasLoading] = useState(true);
const [titulo, setTitulo] = useState("");
const [descricao, setDescricao] = useState("");
const [areaId, setAreaId] = useState("");
const [conteudo, setConteudo] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(false);
const [assistantMode, setAssistantMode] = useState<"create" | "refine">("create");
const [assistantInstructions, setAssistantInstructions] = useState("");
const [isAssistantLoading, setIsAssistantLoading] = useState(false);
const [isAssistantWorkingInBackground, setIsAssistantWorkingInBackground] = useState(false);
const [showGeneratingModal, setShowGeneratingModal] = useState(false);
const confettiRef = useRef<ConfettiRef>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const hasContent = conteudo.trim().length > 0;
const effectiveAreaId = areaId && areaItems.some((a) => a.id === areaId) ? areaId : (areaItems[0]?.id ?? "");
const effectiveAreaName = areaItems.find((a) => a.id === effectiveAreaId)?.nome ?? "";
useEffect(() => {
setAreasLoading(true);
areasService.listarTotal()
.then((data) => setAreaItems(data))
.catch(() => setAreaItems([]))
.finally(() => setAreasLoading(false));
}, []);
useEffect(() => {
if (prompt) {
setTitulo(prompt.titulo);
setDescricao(prompt.descricao ?? "");
setAreaId(prompt.area_id ?? "");
setConteudo(prompt.conteudo ?? "");
} else if (!id) {
setTitulo("");
setDescricao("");
setAreaId("");
setConteudo("");
}
}, [prompt, id]);
useEffect(() => {
if (!id && areaItems.length > 0 && !areaId) {
setAreaId(areaItems[0].id);
}
}, [id, areaItems, areaId]);
useEffect(() => {
if (id && !prompt && prompts.length > 0) {
navigate("/codex/prompts", { replace: true });
}
}, [id, prompt, prompts.length, navigate]);
useEffect(() => {
if (!conteudo.trim() && assistantMode === "refine") {
setAssistantMode("create");
}
}, [conteudo, assistantMode]);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
const handleSave = async () => {
const finalTitulo = titulo.trim();
const finalAreaId = effectiveAreaId;
const finalConteudo = conteudo.trim();
if (!finalTitulo || !finalAreaId) {
toast.error("Preencha o título e a área.");
return;
}
if (!finalConteudo) {
toast.error("Preencha o conteúdo do prompt.");
return;
}
const finalAreaName = areaItems.find((a) => a.id === finalAreaId)?.nome ?? "";
const finalDescricao = descricao.trim();
setIsSaving(true);
try {
if (isEdit && prompt) {
const res = await promptsService.editar(prompt.id, {
titulo: finalTitulo,
descricao: finalDescricao,
area_id: finalAreaId,
conteudo: finalConteudo,
});
setPrompts((prev) =>
prev.map((p) =>
p.id === prompt.id
? { ...p, id: res.id, titulo: res.titulo, area: finalAreaName, area_id: res.area_id, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }
: p
)
);
toast.success("Prompt atualizado.");
} else {
const res = await promptsService.criar({
titulo: finalTitulo,
descricao: finalDescricao,
area_id: finalAreaId,
conteudo: finalConteudo,
});
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
toast.success("Prompt criado.");
}
navigate("/codex/prompts");
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleAssistantSubmit = () => {
if (!assistantInstructions.trim()) {
toast.error("Preencha as instruções.");
return;
}
if (assistantMode === "refine" && !conteudo.trim()) {
toast.error("Não há conteúdo para melhorar.");
return;
}
setIsAssistantOpen(false);
setShowGeneratingModal(true);
setIsAssistantWorkingInBackground(true);
setIsAssistantLoading(true);
const ac = new AbortController();
abortControllerRef.current = ac;
const signal = ac.signal;
const onSuccess = (text: string) => {
setConteudo(text);
confettiRef.current?.fire({});
playSuccessSound();
const msg =
assistantMode === "create" ? "Modelo inicial gerado!" : "Conteúdo atualizado!";
toast.success(msg, {
className: "bg-green-600 border-green-700 text-white",
});
};
const onFinish = () => {
setShowGeneratingModal(false);
setIsAssistantLoading(false);
setIsAssistantWorkingInBackground(false);
abortControllerRef.current = null;
};
const onError = (e: unknown) => {
const isAborted =
(e && typeof e === "object" && "code" in e && (e as { code: string }).code === "ERR_CANCELED") ||
(e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError");
if (!isAborted) {
const message =
e && typeof e === "object" && "message" in e
? (e as { message: string }).message
: "Erro ao processar.";
toast.error(message);
}
};
if (assistantMode === "create") {
assistentePromptsService.gerar(assistantInstructions, signal)
.then(onSuccess)
.catch(onError)
.finally(onFinish);
} else {
assistentePromptsService.refinar(conteudo, assistantInstructions, signal)
.then(onSuccess)
.catch(onError)
.finally(onFinish);
}
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0 relative">
<Confetti
ref={confettiRef}
manualstart
className="absolute inset-0 size-full pointer-events-none z-50"
/>
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
<div className="flex flex-col gap-3">
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/codex/prompts")}>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[200px] space-y-2">
<Label htmlFor="form-titulo">Título</Label>
<Input
id="form-titulo"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Ex: Prompt para análise de documentos"
/>
</div>
<div className="w-full sm:w-[220px] space-y-2">
<Label htmlFor="form-area">Área</Label>
<Select value={effectiveAreaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
<SelectTrigger id="form-area">
<SelectValue placeholder={areasLoading ? "Carregando áreas..." : "Selecione a área"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Selecione a área</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="form-descricao">Descrição <span className="text-muted-foreground font-normal">(opcional)</span></Label>
<Input
id="form-descricao"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
placeholder="Ex: Breve descrição do prompt"
/>
</div>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 p-3 md:p-6 gap-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<Label htmlFor="form-conteudo">Conteúdo do Prompt</Label>
<div className="flex items-center gap-2">
{isAssistantWorkingInBackground && (
<div
className="w-2 h-2 rounded-full bg-primary animate-pulse-glow shrink-0"
title="Gerando em andamento"
/>
)}
<ShimmerButton
type="button"
onClick={() => setIsAssistantOpen(true)}
disabled={isAssistantWorkingInBackground}
background="hsl(var(--card))"
shimmerColor="hsl(var(--foreground) / 0.14)"
className={cn(
"gap-2 px-5 py-2.5 text-sm font-medium text-card-foreground border-border shadow-md hover:shadow-lg transition-shadow",
isAssistantWorkingInBackground && "opacity-70 pointer-events-none"
)}
>
{isAssistantWorkingInBackground ? (
<span className="text-sm font-medium text-card-foreground">
Gerando em segundo plano...
</span>
) : (
<SparklesText sparklesCount={5} className="text-sm font-medium text-card-foreground">
Assistente de Criação de Prompt
</SparklesText>
)}
</ShimmerButton>
</div>
</div>
<Textarea
id="form-conteudo"
value={conteudo}
onChange={(e) => setConteudo(e.target.value)}
placeholder="Digite o conteúdo do prompt..."
className="flex-1 min-h-[200px] resize-none p-3"
/>
<div className="flex justify-end pt-2">
<InteractiveHoverButton
type="button"
onClick={handleSave}
disabled={!titulo.trim() || !effectiveAreaId || !conteudo.trim() || isSaving}
className="disabled:opacity-50 disabled:pointer-events-none"
>
{isSaving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar prompt"}
</InteractiveHoverButton>
</div>
</div>
{showGeneratingModal && (
<PromptGeneratingScreen
mode={assistantMode}
message="Está em processamento. Aguarde que em breve o prompt ficará pronto. O modal será fechado assim que finalizar."
/>
)}
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-8 gap-6 overflow-hidden rounded-xl border-border/80 bg-gradient-to-b from-background to-muted/20">
<div className="absolute inset-0 overflow-hidden rounded-[inherit] pointer-events-none">
<BorderBeam duration={8} size={100} colorFrom="#a78bfa" colorTo="#06b6d4" />
</div>
<DialogHeader className="space-y-4 relative z-10">
<div className="flex justify-center">
<div
className={cn(
"inline-flex w-fit rounded-full border border-black/5 bg-neutral-100 text-base transition-all ease-in hover:bg-neutral-200 dark:border-white/5 dark:bg-neutral-900 dark:hover:bg-neutral-800"
)}
>
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-2 transition ease-out hover:text-neutral-600 hover:duration-300 hover:dark:text-neutral-400">
<span> Assistente de Criação de Prompt</span>
</AnimatedShinyText>
</div>
</div>
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
<DialogDescription className="text-base text-muted-foreground">
Escolha criar um novo prompt do zero ou melhorar o conteúdo atual. Preencha as instruções e o assistente gerará o texto.
</DialogDescription>
</DialogHeader>
<div className="space-y-8 overflow-y-auto flex-1 min-h-0 px-1 pt-1 pb-2 pr-3 relative z-10">
<div className="space-y-4">
<Label className="text-base font-medium">O que deseja fazer?</Label>
<RadioGroup
value={assistantMode}
onValueChange={(v) => setAssistantMode(v as "create" | "refine")}
className="grid gap-4"
>
<div className="flex items-center space-x-3">
<RadioGroupItem value="create" id="assistant-create" />
<Label htmlFor="assistant-create" className="font-normal cursor-pointer">
Criar novo
</Label>
</div>
<div className="flex items-center space-x-3">
<RadioGroupItem
value="refine"
id="assistant-refine"
disabled={!hasContent}
/>
<Label
htmlFor="assistant-refine"
className={`font-normal ${!hasContent ? "cursor-not-allowed text-muted-foreground" : "cursor-pointer"}`}
>
Melhorar atual
{!hasContent && " (preencha o conteúdo do prompt antes)"}
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-4">
<Label htmlFor="assistant-instructions" className="text-base font-medium">
Instruções
</Label>
<Textarea
id="assistant-instructions"
value={assistantInstructions}
onChange={(e) => setAssistantInstructions(e.target.value)}
placeholder={
assistantMode === "create"
? "Ex: Um prompt que resuma reuniões em tópicos e ações..."
: "Ex: Tornar mais conciso, adicionar seção de exemplos..."
}
rows={8}
className="resize-none text-base py-4 px-4 min-h-[180px]"
/>
</div>
</div>
<DialogFooter className="gap-3 pt-4 relative z-10">
<Button variant="outline" className="hover:bg-slate-100 hover:text-slate-800 dark:hover:bg-slate-800" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
Fechar
</Button>
<Button
onClick={handleAssistantSubmit}
disabled={
!assistantInstructions.trim() ||
isAssistantLoading ||
(assistantMode === "refine" && !hasContent)
}
className="gap-2"
>
<Sparkles className="w-4 h-4 shrink-0" />
{assistantMode === "create" ? "Gerar" : "Melhorar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+459
View File
@@ -0,0 +1,459 @@
import { useState, useMemo, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Plus, Eye, Edit, Copy, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import type { Prompt } from "@/contexts/PromptsContext";
import { promptsService, type PromptItem } from "@/services/promptsApi";
import { toast } from "sonner";
function formatarDataCriacao(iso: string | undefined): string {
if (!iso?.trim()) return "—";
try {
return new Date(iso.replace(" ", "T")).toLocaleDateString("pt-BR");
} catch {
return iso;
}
}
function apiItemToPrompt(item: PromptItem): Prompt {
return {
id: item.id,
titulo: item.titulo,
area: item.area_nome,
area_id: item.area_id,
descricao: item.descricao ?? undefined,
conteudo: item.conteudo ?? "",
created_at: item.created_at,
};
}
export const PromptsView = () => {
const navigate = useNavigate();
const { prompts, setPrompts, areaItems, refreshAreas } = usePrompts();
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [promptToDelete, setPromptToDelete] = useState<Prompt | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [filterNome, setFilterNome] = useState("");
const [filterAreaId, setFilterAreaId] = useState<string>("");
const [promptsList, setPromptsList] = useState<Prompt[]>([]);
const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1);
const [loadingList, setLoadingList] = useState(true);
const [isDeletingPrompt, setIsDeletingPrompt] = useState(false);
const totalPages = Math.max(1, totalPaginas);
const selectedAreaItem = useMemo(
() => (selectedPrompt ? areaItems.find((a) => a.nome === selectedPrompt.area || a.id === selectedPrompt.area_id) ?? null : null),
[selectedPrompt, areaItems]
);
useEffect(() => {
let cancelled = false;
setLoadingList(true);
promptsService.listar({
titulo: filterNome.trim() || undefined,
area_id: filterAreaId.trim() || undefined,
page: currentPage,
per_page: itemsPerPage,
})
.then((res) => {
if (!cancelled) {
const list = (res.data ?? []).map(apiItemToPrompt);
setPromptsList(list);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setPrompts((prev) => {
const byId = new Map(prev.map((p) => [p.id, p]));
list.forEach((p) => byId.set(p.id, p));
return Array.from(byId.values());
});
}
})
.catch((e: unknown) => {
if (!cancelled) {
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao carregar prompts.";
toast.error(msg);
setPromptsList([]);
setTotalRegistros(0);
setTotalPaginas(1);
}
})
.finally(() => {
if (!cancelled) setLoadingList(false);
});
return () => { cancelled = true; };
}, [filterNome, filterAreaId, currentPage, itemsPerPage]);
useEffect(() => {
setCurrentPage(1);
}, [filterNome, filterAreaId]);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
const clearFilters = () => {
setFilterNome("");
setFilterAreaId("");
setCurrentPage(1);
};
const hasActiveFilters = filterNome.trim() !== "" || filterAreaId !== "";
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
const openDetailsDialog = (prompt: Prompt) => {
setSelectedPrompt(prompt);
setIsDetailsDialogOpen(true);
};
const openEditFromDetails = () => {
if (!selectedPrompt) return;
setIsDetailsDialogOpen(false);
navigate(`/codex/prompts/${selectedPrompt.id}`);
};
const openDeleteDialog = (prompt: Prompt) => {
setPromptToDelete(prompt);
setIsDeleteDialogOpen(true);
};
const handleDeletePrompt = async () => {
if (!promptToDelete) return;
setIsDeletingPrompt(true);
try {
await promptsService.deletar(promptToDelete.id);
setPromptsList((prev) => prev.filter((p) => p.id !== promptToDelete.id));
setPrompts((prev) => prev.filter((p) => p.id !== promptToDelete.id));
setTotalRegistros((prev) => Math.max(0, prev - 1));
if (selectedPrompt?.id === promptToDelete.id) {
setIsDetailsDialogOpen(false);
setSelectedPrompt(null);
}
toast.success("Prompt excluído com sucesso!");
setIsDeleteDialogOpen(false);
setPromptToDelete(null);
setCurrentPage(1);
} catch (e: unknown) {
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao excluir prompt.";
toast.error(msg);
} finally {
setIsDeletingPrompt(false);
}
};
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<FileText className="w-5 h-5 md:w-6 md:h-6" />
Prompts
</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => navigate("/codex/prompts/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo prompt</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
<Input
placeholder="Buscar prompts..."
value={filterNome}
onChange={(e) => setFilterNome(e.target.value)}
className="w-full md:max-w-sm text-sm"
/>
<Select value={filterAreaId || "all"} onValueChange={(v) => setFilterAreaId(v === "all" ? "" : v)}>
<SelectTrigger className="w-full md:w-[180px] text-sm">
<SelectValue placeholder="Todas as áreas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as áreas</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Limpar
</Button>
)}
<div className="flex items-center gap-2 justify-between md:justify-start">
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Nenhum prompt cadastrado
</CardTitle>
<CardDescription>
Crie seu primeiro prompt para organizar por título e área.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<Button onClick={() => navigate("/codex/prompts/novo")}>
<Plus className="w-4 h-4 mr-2" />
Novo prompt
</Button>
</div>
</CardContent>
</Card>
) : (
<>
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px] whitespace-nowrap font-semibold text-xs md:text-sm">
Data de criação
</TableHead>
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
Carregando...
</TableCell>
</TableRow>
) : totalRegistros === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-10 text-base">
{hasActiveFilters ? "Nenhum prompt encontrado para os filtros selecionados." : "Não há resultados."}
</TableCell>
</TableRow>
) : (
promptsList.map((prompt) => (
<TableRow key={prompt.id}>
<TableCell className="text-xs md:text-sm whitespace-nowrap tabular-nums">
{formatarDataCriacao(prompt.created_at)}
</TableCell>
<TableCell className="font-medium text-xs md:text-sm">{prompt.titulo}</TableCell>
<TableCell className="text-xs md:text-sm">{prompt.area}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openDetailsDialog(prompt)}
title="Detalhes"
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
>
<Eye className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/codex/prompts/${prompt.id}`)}
title="Editar"
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
>
<Edit className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openDeleteDialog(prompt)}
title="Excluir"
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive hover:bg-slate-300 dark:hover:bg-slate-700"
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loadingList && totalRegistros > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {totalRegistros} {totalRegistros === 1 ? "prompt" : "prompts"}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page: number;
if (totalPages <= 5) {
page = i + 1;
} else if (currentPage <= 3) {
page = i + 1;
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i;
} else {
page = currentPage - 2 + i;
}
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)}
</>
)}
</div>
{/* Modal Detalhes */}
<Dialog open={isDetailsDialogOpen} onOpenChange={setIsDetailsDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Detalhes do Prompt</DialogTitle>
<DialogDescription>
Informações do prompt selecionado
</DialogDescription>
</DialogHeader>
{selectedPrompt && (
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 p-3">
{selectedPrompt.created_at?.trim() ? (
<div>
<Label className="text-muted-foreground">Data de criação</Label>
<p className="mt-1 text-sm font-medium tabular-nums">{formatarDataCriacao(selectedPrompt.created_at)}</p>
</div>
) : null}
<div>
<Label className="text-muted-foreground">Título</Label>
<p className="font-medium mt-1">{selectedPrompt.titulo}</p>
</div>
<div>
<Label className="text-muted-foreground">Área</Label>
<p className="mt-1 font-medium">{selectedPrompt.area}</p>
{selectedAreaItem?.descricao?.trim() && (
<p className="mt-1 text-sm text-muted-foreground">{selectedAreaItem.descricao}</p>
)}
</div>
{selectedPrompt.conteudo != null && selectedPrompt.conteudo !== "" && (
<div>
<Label className="text-muted-foreground">Conteúdo</Label>
<div className="mt-1 rounded-md border bg-muted/30 p-4 max-h-[50vh] overflow-y-auto">
<p className="whitespace-pre-wrap text-sm leading-relaxed">{selectedPrompt.conteudo}</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={async () => {
if (selectedPrompt?.conteudo != null) {
await navigator.clipboard.writeText(selectedPrompt.conteudo);
toast.success("Prompt copiado para a área de transferência.");
} else {
toast.error("Nenhum conteúdo para copiar.");
}
}}
disabled={!selectedPrompt?.conteudo?.trim()}
>
<Copy className="w-4 h-4 mr-2" />
Copiar prompt
</Button>
<Button variant="outline" onClick={() => setIsDetailsDialogOpen(false)}>
Fechar
</Button>
<Button onClick={openEditFromDetails}>
<Edit className="w-4 h-4 mr-2" />
Editar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir prompt</AlertDialogTitle>
<AlertDialogDescription>
Deseja excluir o prompt "{promptToDelete?.titulo ?? ""}"? Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingPrompt}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeletePrompt}
disabled={isDeletingPrompt || !promptToDelete}
className={(isDeletingPrompt || !promptToDelete) ? "opacity-50 cursor-not-allowed" : "bg-destructive text-destructive-foreground hover:bg-destructive/90"}
>
{isDeletingPrompt ? "Excluindo..." : "Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
+183
View File
@@ -0,0 +1,183 @@
import { RefObject, useEffect, useId, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
export interface AnimatedBeamProps {
className?: string
containerRef: RefObject<HTMLElement | null> // Container ref
fromRef: RefObject<HTMLElement | null>
toRef: RefObject<HTMLElement | null>
curvature?: number
reverse?: boolean
pathColor?: string
pathWidth?: number
pathOpacity?: number
gradientStartColor?: string
gradientStopColor?: string
delay?: number
duration?: number
startXOffset?: number
startYOffset?: number
endXOffset?: number
endYOffset?: number
}
export const AnimatedBeam: React.FC<AnimatedBeamProps> = ({
className,
containerRef,
fromRef,
toRef,
curvature = 0,
reverse = false, // Include the reverse prop
duration = Math.random() * 3 + 4,
delay = 0,
pathColor = "gray",
pathWidth = 2,
pathOpacity = 0.2,
gradientStartColor = "#ffaa40",
gradientStopColor = "#9c40ff",
startXOffset = 0,
startYOffset = 0,
endXOffset = 0,
endYOffset = 0,
}) => {
const id = useId()
const [pathD, setPathD] = useState("")
const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 })
// Calculate the gradient coordinates based on the reverse prop
const gradientCoordinates = reverse
? {
x1: ["90%", "-10%"],
x2: ["100%", "0%"],
y1: ["0%", "0%"],
y2: ["0%", "0%"],
}
: {
x1: ["10%", "110%"],
x2: ["0%", "100%"],
y1: ["0%", "0%"],
y2: ["0%", "0%"],
}
useEffect(() => {
const updatePath = () => {
if (containerRef.current && fromRef.current && toRef.current) {
const containerRect = containerRef.current.getBoundingClientRect()
const rectA = fromRef.current.getBoundingClientRect()
const rectB = toRef.current.getBoundingClientRect()
const svgWidth = containerRect.width
const svgHeight = containerRect.height
setSvgDimensions({ width: svgWidth, height: svgHeight })
const startX =
rectA.left - containerRect.left + rectA.width / 2 + startXOffset
const startY =
rectA.top - containerRect.top + rectA.height / 2 + startYOffset
const endX =
rectB.left - containerRect.left + rectB.width / 2 + endXOffset
const endY =
rectB.top - containerRect.top + rectB.height / 2 + endYOffset
const controlY = startY - curvature
const d = `M ${startX},${startY} Q ${
(startX + endX) / 2
},${controlY} ${endX},${endY}`
setPathD(d)
}
}
// Initialize ResizeObserver
const resizeObserver = new ResizeObserver(() => {
updatePath()
})
// Observe the container element
if (containerRef.current) {
resizeObserver.observe(containerRef.current)
}
// Call the updatePath initially to set the initial path
updatePath()
// Clean up the observer on component unmount
return () => {
resizeObserver.disconnect()
}
}, [
containerRef,
fromRef,
toRef,
curvature,
startXOffset,
startYOffset,
endXOffset,
endYOffset,
])
return (
<svg
fill="none"
width={svgDimensions.width}
height={svgDimensions.height}
xmlns="http://www.w3.org/2000/svg"
className={cn(
"pointer-events-none absolute top-0 left-0 transform-gpu stroke-2",
className
)}
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
>
<path
d={pathD}
stroke={pathColor}
strokeWidth={pathWidth}
strokeOpacity={pathOpacity}
strokeLinecap="round"
/>
<path
d={pathD}
strokeWidth={pathWidth}
stroke={`url(#${id})`}
strokeOpacity="1"
strokeLinecap="round"
/>
<defs>
<motion.linearGradient
className="transform-gpu"
id={id}
gradientUnits={"userSpaceOnUse"}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: gradientCoordinates.x1,
x2: gradientCoordinates.x2,
y1: gradientCoordinates.y1,
y2: gradientCoordinates.y2,
}}
transition={{
delay,
duration,
ease: [0.16, 1, 0.3, 1], // https://easings.net/#easeOutExpo
repeat: Infinity,
repeatDelay: 0,
}}
>
<stop stopColor={gradientStartColor} stopOpacity="0"></stop>
<stop stopColor={gradientStartColor}></stop>
<stop offset="32.5%" stopColor={gradientStopColor}></stop>
<stop
offset="100%"
stopColor={gradientStopColor}
stopOpacity="0"
></stop>
</motion.linearGradient>
</defs>
</svg>
)
}
@@ -0,0 +1,37 @@
import { type ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
speed?: number
colorFrom?: string
colorTo?: string
}
export function AnimatedGradientText({
children,
className,
speed = 1,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
...props
}: AnimatedGradientTextProps) {
return (
<span
style={
{
"--bg-size": `${speed * 300}%`,
"--color-from": colorFrom,
"--color-to": colorTo,
} as React.CSSProperties
}
className={cn(
`animate-gradient inline bg-linear-to-r from-(--color-from) via-(--color-to) to-(--color-from) bg-size-[var(--bg-size)_100%] bg-clip-text text-transparent`,
className
)}
{...props}
>
{children}
</span>
)
}
+175
View File
@@ -0,0 +1,175 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
} from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
export interface AnimatedGridPatternProps extends ComponentPropsWithoutRef<"svg"> {
width?: number
height?: number
x?: number
y?: number
strokeDasharray?: number
numSquares?: number
maxOpacity?: number
duration?: number
repeatDelay?: number
}
type Square = {
id: number
pos: [number, number]
iteration: number
}
export function AnimatedGridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = 0,
numSquares = 50,
className,
maxOpacity = 0.5,
duration = 4,
repeatDelay = 0.5,
...props
}: AnimatedGridPatternProps) {
const id = useId()
const containerRef = useRef<SVGSVGElement | null>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
const [squares, setSquares] = useState<Array<Square>>([])
const getPos = useCallback((): [number, number] => {
return [
Math.floor((Math.random() * dimensions.width) / width),
Math.floor((Math.random() * dimensions.height) / height),
]
}, [dimensions.height, dimensions.width, height, width])
const generateSquares = useCallback(
(count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i,
pos: getPos(),
iteration: 0,
}))
},
[getPos]
)
const updateSquarePosition = useCallback(
(squareId: number) => {
setSquares((currentSquares) => {
const current = currentSquares[squareId]
if (!current || current.id !== squareId) return currentSquares
const nextSquares = currentSquares.slice()
nextSquares[squareId] = {
...current,
pos: getPos(),
iteration: current.iteration + 1,
}
return nextSquares
})
},
[getPos]
)
useEffect(() => {
if (dimensions.width && dimensions.height) {
setSquares(generateSquares(numSquares))
}
}, [dimensions.width, dimensions.height, generateSquares, numSquares])
useEffect(() => {
const element = containerRef.current
let resizeObserver: ResizeObserver | null = null
if (element) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setDimensions((currentDimensions) => {
const nextWidth = entry.contentRect.width
const nextHeight = entry.contentRect.height
if (
currentDimensions.width === nextWidth &&
currentDimensions.height === nextHeight
) {
return currentDimensions
}
return { width: nextWidth, height: nextHeight }
})
}
})
resizeObserver.observe(element)
}
return () => {
if (resizeObserver) {
resizeObserver.disconnect()
}
}
}, [])
return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
className
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
<svg x={x} y={y} className="overflow-visible">
{squares.map(({ pos: [squareX, squareY], id, iteration }, index) => (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: maxOpacity }}
transition={{
duration,
repeat: 1,
delay: index * 0.1,
repeatType: "reverse",
repeatDelay,
}}
onAnimationComplete={() => updateSquarePosition(id)}
key={`${id}-${iteration}`}
width={width - 1}
height={height - 1}
x={squareX * width + 1}
y={squareY * height + 1}
fill="currentColor"
strokeWidth="0"
/>
))}
</svg>
</svg>
)
}
+42
View File
@@ -0,0 +1,42 @@
import {
type ComponentPropsWithoutRef,
type CSSProperties,
type FC,
} from "react"
import { cn } from "@/lib/utils"
export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> {
shimmerWidth?: number
}
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
children,
className,
shimmerWidth = 100,
...props
}) => {
return (
<span
style={
{
"--shiny-width": `${shimmerWidth}px`,
} as CSSProperties
}
className={cn(
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
// Shine effect
"animate-shiny-text bg-size-[var(--shiny-width)_100%] bg-clip-text bg-position-[0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
// Shine gradient
"bg-linear-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
className
)}
{...props}
>
{children}
</span>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { motion, MotionStyle, Transition } from "motion/react"
import { cn } from "@/lib/utils"
interface BorderBeamProps {
/**
* The size of the border beam.
*/
size?: number
/**
* The duration of the border beam.
*/
duration?: number
/**
* The delay of the border beam.
*/
delay?: number
/**
* The color of the border beam from.
*/
colorFrom?: string
/**
* The color of the border beam to.
*/
colorTo?: string
/**
* The motion transition of the border beam.
*/
transition?: Transition
/**
* The class name of the border beam.
*/
className?: string
/**
* The style of the border beam.
*/
style?: React.CSSProperties
/**
* Whether to reverse the animation direction.
*/
reverse?: boolean
/**
* The initial offset position (0-100).
*/
initialOffset?: number
/**
* The border width of the beam.
*/
borderWidth?: number
}
export const BorderBeam = ({
className,
size = 50,
delay = 0,
duration = 6,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
transition,
style,
reverse = false,
initialOffset = 0,
borderWidth = 1,
}: BorderBeamProps) => {
return (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent mask-[linear-gradient(transparent,transparent),linear-gradient(#000,#000)] mask-intersect [mask-clip:padding-box,border-box]"
style={
{
"--border-beam-width": `${borderWidth}px`,
} as React.CSSProperties
}
>
<motion.div
className={cn(
"absolute aspect-square",
"bg-linear-to-l from-(--color-from) via-(--color-to) to-transparent",
className
)}
style={
{
width: size,
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
"--color-from": colorFrom,
"--color-to": colorTo,
...style,
} as MotionStyle
}
initial={{ offsetDistance: `${initialOffset}%` }}
animate={{
offsetDistance: reverse
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
: [`${initialOffset}%`, `${100 + initialOffset}%`],
}}
transition={{
repeat: Infinity,
ease: "linear",
duration,
delay: -delay,
...transition,
}}
/>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import type { ReactNode } from "react";
import React, {
createContext,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import type {
GlobalOptions as ConfettiGlobalOptions,
CreateTypes as ConfettiInstance,
Options as ConfettiOptions,
} from "canvas-confetti";
import confetti from "canvas-confetti";
import { Button } from "@/components/ui/button";
type Api = {
fire: (options?: ConfettiOptions) => void;
};
type Props = React.ComponentPropsWithRef<"canvas"> & {
options?: ConfettiOptions;
globalOptions?: ConfettiGlobalOptions;
manualstart?: boolean;
children?: ReactNode;
};
export type ConfettiRef = Api | null;
const ConfettiContext = createContext<Api>({} as Api);
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
const {
options,
globalOptions = { resize: true, useWorker: true },
manualstart = false,
children,
...rest
} = props;
const instanceRef = useRef<ConfettiInstance | null>(null);
const canvasRef = useCallback(
(node: HTMLCanvasElement) => {
if (node !== null) {
if (instanceRef.current) return;
instanceRef.current = confetti.create(node, {
...globalOptions,
resize: true,
});
} else {
if (instanceRef.current) {
instanceRef.current.reset();
instanceRef.current = null;
}
}
},
[globalOptions]
);
const fire = useCallback(
async (opts = {}) => {
try {
await instanceRef.current?.({ ...options, ...opts });
} catch (error) {
console.error("Confetti error:", error);
}
},
[options]
);
const api = useMemo(
() => ({
fire,
}),
[fire]
);
useImperativeHandle(ref, () => api, [api]);
useEffect(() => {
if (!manualstart) {
(async () => {
try {
await fire();
} catch (error) {
console.error("Confetti effect error:", error);
}
})();
}
}, [manualstart, fire]);
return (
<ConfettiContext.Provider value={api}>
<canvas ref={canvasRef} {...rest} />
{children}
</ConfettiContext.Provider>
);
});
ConfettiComponent.displayName = "Confetti";
export const Confetti = ConfettiComponent;
interface ConfettiButtonProps extends React.ComponentProps<"button"> {
options?: ConfettiOptions &
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
}
const ConfettiButtonComponent = ({
options,
children,
...props
}: ConfettiButtonProps) => {
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
try {
const rect = event.currentTarget.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
await confetti({
...options,
origin: {
x: x / window.innerWidth,
y: y / window.innerHeight,
},
});
} catch (error) {
console.error("Confetti button error:", error);
}
};
return (
<Button onClick={handleClick} {...props}>
{children}
</Button>
);
};
ConfettiButtonComponent.displayName = "ConfettiButton";
export const ConfettiButton = ConfettiButtonComponent;
+11 -3
View File
@@ -27,12 +27,18 @@ const DialogOverlay = React.forwardRef<
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
hideClose?: boolean;
overlayClassName?: string;
}
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> DialogContentProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, hideClose, overlayClassName, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
@@ -42,10 +48,12 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
{!hideClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)); ));
@@ -0,0 +1,30 @@
import { ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
export function InteractiveHoverButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className={cn(
"group bg-background relative w-auto cursor-pointer overflow-hidden rounded-full border p-2 px-6 text-center font-semibold",
className
)}
{...props}
>
<div className="flex items-center justify-center gap-2">
<div className="bg-primary h-2 w-2 rounded-full transition-all duration-300 group-hover:scale-[100.8]"></div>
<span className="inline-block transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
{children}
</span>
</div>
<div className="text-primary-foreground absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
<span>{children}</span>
<ArrowRight />
</div>
</button>
)
}
+146
View File
@@ -0,0 +1,146 @@
import {
CSSProperties,
ReactElement,
ReactNode,
useEffect,
useRef,
useState,
} from "react"
import { cn } from "@/lib/utils"
interface NeonColorsProps {
firstColor: string
secondColor: string
}
interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* @default <div />
* @type ReactElement
* @description
* The component to be rendered as the card
* */
as?: ReactElement
/**
* @default ""
* @type string
* @description
* The className of the card
*/
className?: string
/**
* @default ""
* @type ReactNode
* @description
* The children of the card
* */
children?: ReactNode
/**
* @default 5
* @type number
* @description
* The size of the border in pixels
* */
borderSize?: number
/**
* @default 20
* @type number
* @description
* The size of the radius in pixels
* */
borderRadius?: number
/**
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
* @type string
* @description
* The colors of the neon gradient
* */
neonColors?: NeonColorsProps
}
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
className,
children,
borderSize = 2,
borderRadius = 20,
neonColors = {
firstColor: "#ff00aa",
secondColor: "#00FFF1",
},
...props
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
setDimensions({ width: offsetWidth, height: offsetHeight })
}
}
updateDimensions()
window.addEventListener("resize", updateDimensions)
return () => {
window.removeEventListener("resize", updateDimensions)
}
}, [])
useEffect(() => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
setDimensions({ width: offsetWidth, height: offsetHeight })
}
}, [children])
return (
<div
ref={containerRef}
style={
{
"--border-size": `${borderSize}px`,
"--border-radius": `${borderRadius}px`,
"--neon-first-color": neonColors.firstColor,
"--neon-second-color": neonColors.secondColor,
"--card-width": `${dimensions.width}px`,
"--card-height": `${dimensions.height}px`,
"--card-content-radius": `${borderRadius - borderSize}px`,
"--pseudo-element-background-image": `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
"--pseudo-element-width": `${dimensions.width + borderSize * 2}px`,
"--pseudo-element-height": `${dimensions.height + borderSize * 2}px`,
"--after-blur": `${dimensions.width / 3}px`,
} as CSSProperties
}
className={cn(
"relative z-10 size-full rounded-(--border-radius)",
className
)}
{...props}
>
<div
className={cn(
"relative size-full min-h-[inherit] rounded-(--card-content-radius) bg-gray-100 p-6",
"before:absolute before:-top-(--border-size) before:-left-(--border-size) before:-z-10 before:block",
"before:h-(--pseudo-element-height) before:w-(--pseudo-element-width) before:rounded-(--border-radius) before:content-['']",
"before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-size-[100%_200%]",
"before:animate-background-position-spin",
"after:absolute after:-top-(--border-size) after:-left-(--border-size) after:-z-10 after:block",
"after:h-(--pseudo-element-height) after:w-(--pseudo-element-width) after:rounded-(--border-radius) after:blur-(--after-blur) after:content-['']",
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-size-[100%_200%] after:opacity-80",
"after:animate-background-position-spin",
"dark:bg-neutral-900",
"wrap-break-word"
)}
>
{children}
</div>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const rainbowButtonVariants = cva(
cn(
"relative cursor-pointer group transition-all animate-rainbow",
"inline-flex items-center justify-center gap-2 shrink-0",
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
"text-sm font-medium whitespace-nowrap",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
),
{
variants: {
variant: {
default:
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
outline:
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-xl px-3 text-xs",
lg: "h-11 rounded-xl px-8",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
interface RainbowButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof rainbowButtonVariants> {
asChild?: boolean
}
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(rainbowButtonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
RainbowButton.displayName = "RainbowButton"
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
+99
View File
@@ -0,0 +1,99 @@
import React, { MouseEvent, useEffect, useState } from "react"
import { cn } from "@/lib/utils"
interface RippleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
rippleColor?: string
duration?: string
}
export const RippleButton = React.forwardRef<
HTMLButtonElement,
RippleButtonProps
>(
(
{
className,
children,
rippleColor = "#ffffff",
duration = "600ms",
onClick,
...props
},
ref
) => {
const [buttonRipples, setButtonRipples] = useState<
Array<{ x: number; y: number; size: number; key: number }>
>([])
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
createRipple(event)
onClick?.(event)
}
const createRipple = (event: MouseEvent<HTMLButtonElement>) => {
const button = event.currentTarget
const rect = button.getBoundingClientRect()
const size = Math.max(rect.width, rect.height)
const x = event.clientX - rect.left - size / 2
const y = event.clientY - rect.top - size / 2
const newRipple = { x, y, size, key: Date.now() }
setButtonRipples((prevRipples) => [...prevRipples, newRipple])
}
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (buttonRipples.length > 0) {
const lastRipple = buttonRipples[buttonRipples.length - 1]
timeout = setTimeout(() => {
setButtonRipples((prevRipples) =>
prevRipples.filter((ripple) => ripple.key !== lastRipple.key)
)
}, parseInt(duration))
}
return () => {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [buttonRipples, duration])
return (
<button
className={cn(
"bg-background text-primary relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 px-4 py-2 text-center",
className
)}
onClick={handleClick}
ref={ref}
{...props}
>
<div className="relative z-10">{children}</div>
<span className="pointer-events-none absolute inset-0">
{buttonRipples.map((ripple) => (
<span
className="animate-rippling bg-background absolute rounded-full opacity-30"
key={ripple.key}
style={
{
width: `${ripple.size}px`,
height: `${ripple.size}px`,
top: `${ripple.y}px`,
left: `${ripple.x}px`,
backgroundColor: rippleColor,
transform: `scale(0)`,
"--duration": duration,
} as React.CSSProperties
}
/>
))}
</span>
</button>
)
}
)
RippleButton.displayName = "RippleButton"
+58
View File
@@ -0,0 +1,58 @@
import React, { type ComponentPropsWithoutRef, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
interface RippleProps extends ComponentPropsWithoutRef<"div"> {
mainCircleSize?: number
mainCircleOpacity?: number
numCircles?: number
}
export const Ripple = React.memo(function Ripple({
mainCircleSize = 210,
mainCircleOpacity = 0.24,
numCircles = 8,
className,
...props
}: RippleProps) {
return (
<div
className={cn(
"pointer-events-none absolute inset-0 mask-[linear-gradient(to_bottom,white,transparent)] select-none",
className
)}
{...props}
>
{Array.from({ length: numCircles }, (_, i) => {
const size = mainCircleSize + i * 70
const opacity = mainCircleOpacity - i * 0.03
const animationDelay = `${i * 0.06}s`
const borderStyle = "solid"
return (
<div
key={i}
className={`animate-ripple bg-foreground/25 absolute rounded-full border shadow-xl`}
style={
{
"--i": i,
width: `${size}px`,
height: `${size}px`,
opacity,
animationDelay,
borderStyle,
borderWidth: "1px",
borderColor: `var(--foreground)`,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%) scale(1)",
} as CSSProperties
}
/>
)
})}
</div>
)
})
Ripple.displayName = "Ripple"
+96
View File
@@ -0,0 +1,96 @@
import React, { type ComponentPropsWithoutRef, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> {
shimmerColor?: string
shimmerSize?: string
borderRadius?: string
shimmerDuration?: string
background?: string
className?: string
children?: React.ReactNode
}
export const ShimmerButton = React.forwardRef<
HTMLButtonElement,
ShimmerButtonProps
>(
(
{
shimmerColor = "#ffffff",
shimmerSize = "0.05em",
shimmerDuration = "3s",
borderRadius = "100px",
background = "rgba(0, 0, 0, 1)",
className,
children,
...props
},
ref
) => {
return (
<button
style={
{
"--spread": "90deg",
"--shimmer-color": shimmerColor,
"--radius": borderRadius,
"--speed": shimmerDuration,
"--cut": shimmerSize,
"--bg": background,
} as CSSProperties
}
className={cn(
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-border px-6 py-3 whitespace-nowrap text-card-foreground [background:var(--bg)]",
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
className
)}
ref={ref}
{...props}
>
{/* spark container */}
<div
className={cn(
"-z-30 blur-[2px]",
"@container-[size] absolute inset-0 overflow-visible"
)}
>
{/* spark */}
<div className="animate-shimmer-slide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
{/* spark before */}
<div className="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]" />
</div>
</div>
{children}
{/* Highlight */}
<div
className={cn(
"absolute inset-0 size-full",
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_hsl(var(--foreground)_/_0.08)]",
// transition
"transform-gpu transition-all duration-300 ease-in-out",
// on hover
"group-hover:shadow-[inset_0_-6px_10px_hsl(var(--foreground)_/_0.14)]",
// on click
"group-active:shadow-[inset_0_-10px_10px_hsl(var(--foreground)_/_0.14)]"
)}
/>
{/* backdrop */}
<div
className={cn(
"absolute inset-(--cut) -z-20 [border-radius:var(--radius)] [background:var(--bg)]"
)}
/>
</button>
)
}
)
ShimmerButton.displayName = "ShimmerButton"
+148
View File
@@ -0,0 +1,148 @@
import { CSSProperties, ReactElement, useEffect, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
interface Sparkle {
id: string
x: string
y: string
color: string
delay: number
scale: number
lifespan: number
}
const Sparkle: React.FC<Sparkle> = ({ id, x, y, color, delay, scale }) => {
return (
<motion.svg
key={id}
className="pointer-events-none absolute z-20"
initial={{ opacity: 0, left: x, top: y }}
animate={{
opacity: [0, 1, 0],
scale: [0, scale, 0],
rotate: [75, 120, 150],
}}
transition={{ duration: 0.8, repeat: Infinity, delay }}
width="21"
height="21"
viewBox="0 0 21 21"
>
<path
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
fill={color}
/>
</motion.svg>
)
}
interface SparklesTextProps {
/**
* @default <div />
* @type ReactElement
* @description
* The component to be rendered as the text
* */
as?: ReactElement
/**
* @default ""
* @type string
* @description
* The className of the text
*/
className?: string
/**
* @required
* @type ReactNode
* @description
* The content to be displayed
* */
children: React.ReactNode
/**
* @default 10
* @type number
* @description
* The count of sparkles
* */
sparklesCount?: number
/**
* @default "{first: '#9E7AFF', second: '#FE8BBB'}"
* @type string
* @description
* The colors of the sparkles
* */
colors?: {
first: string
second: string
}
}
export const SparklesText: React.FC<SparklesTextProps> = ({
children,
colors = { first: "#9E7AFF", second: "#FE8BBB" },
className,
sparklesCount = 10,
...props
}) => {
const [sparkles, setSparkles] = useState<Sparkle[]>([])
useEffect(() => {
const generateStar = (): Sparkle => {
const starX = `${Math.random() * 100}%`
const starY = `${Math.random() * 100}%`
const color = Math.random() > 0.5 ? colors.first : colors.second
const delay = Math.random() * 2
const scale = Math.random() * 1 + 0.3
const lifespan = Math.random() * 10 + 5
const id = `${starX}-${starY}-${Date.now()}`
return { id, x: starX, y: starY, color, delay, scale, lifespan }
}
const initializeStars = () => {
const newSparkles = Array.from({ length: sparklesCount }, generateStar)
setSparkles(newSparkles)
}
const updateStars = () => {
setSparkles((currentSparkles) =>
currentSparkles.map((star) => {
if (star.lifespan <= 0) {
return generateStar()
} else {
return { ...star, lifespan: star.lifespan - 0.1 }
}
})
)
}
initializeStars()
const interval = setInterval(updateStars, 100)
return () => clearInterval(interval)
}, [colors.first, colors.second, sparklesCount])
return (
<div
className={cn("text-6xl font-bold", className)}
{...props}
style={
{
"--sparkles-first-color": `${colors.first}`,
"--sparkles-second-color": `${colors.second}`,
} as CSSProperties
}
>
<span className="relative inline-block">
{sparkles.map((sparkle) => (
<Sparkle key={sparkle.id} {...sparkle} />
))}
<strong>{children}</strong>
</span>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* Container com estilo de terminal: fundo escuro, borda, fonte monospace.
*/
const Terminal = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-zinc-900/95 font-mono text-sm shadow-xl",
"p-4 text-foreground",
className
)}
{...props}
>
{children}
</div>
));
Terminal.displayName = "Terminal";
interface AnimatedStepProps extends React.HTMLAttributes<HTMLSpanElement> {
delay?: number;
className?: string;
children?: React.ReactNode;
}
/**
* Linha animada (fade-in + slide up) para uso dentro do Terminal.
* Usado para exibir passos em sequência.
*/
function AnimatedStep({
delay = 0,
className,
children,
...props
}: AnimatedStepProps) {
return (
<motion.span
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay }}
className={cn("block", className)}
{...props}
>
{children}
</motion.span>
);
}
export { Terminal, AnimatedStep };
+234
View File
@@ -0,0 +1,234 @@
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type RefAttributes,
type RefObject,
} from "react"
import {
motion,
useInView,
type DOMMotionComponents,
type HTMLMotionProps,
type MotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const motionElements = {
article: motion.article,
div: motion.div,
h1: motion.h1,
h2: motion.h2,
h3: motion.h3,
h4: motion.h4,
h5: motion.h5,
h6: motion.h6,
li: motion.li,
p: motion.p,
section: motion.section,
span: motion.span,
} as const
type MotionElementType = Extract<
keyof DOMMotionComponents,
keyof typeof motionElements
>
type TypingAnimationMotionComponent = ComponentType<
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
>
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
children?: string
words?: string[]
className?: string
duration?: number
typeSpeed?: number
deleteSpeed?: number
delay?: number
pauseDelay?: number
loop?: boolean
as?: MotionElementType
startOnView?: boolean
showCursor?: boolean
blinkCursor?: boolean
cursorStyle?: "line" | "block" | "underscore"
}
export function TypingAnimation({
children,
words,
className,
duration = 100,
typeSpeed,
deleteSpeed,
delay = 0,
pauseDelay = 1000,
loop = false,
as: Component = "span",
startOnView = true,
showCursor = true,
blinkCursor = true,
cursorStyle = "line",
...props
}: TypingAnimationProps) {
const MotionComponent = motionElements[
Component
] as TypingAnimationMotionComponent
const [displayedText, setDisplayedText] = useState<string>("")
const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as RefObject<Element>, {
amount: 0.3,
once: true,
})
const wordsToAnimate = useMemo(
() => words ?? (children ? [children] : []),
[words, children]
)
const hasMultipleWords = wordsToAnimate.length > 1
const typingSpeed = typeSpeed ?? duration
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
const shouldStart = startOnView ? isInView : true
const animationSourceKey = useMemo(
() => (words ? words.join("\u0000") : (children ?? "")),
[words, children]
)
useEffect(() => {
setDisplayedText("")
setCurrentWordIndex(0)
setCurrentCharIndex(0)
setPhase("typing")
}, [animationSourceKey])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay =
delay > 0 && displayedText === ""
? delay
: phase === "typing"
? typingSpeed
: phase === "deleting"
? deletingSpeed
: pauseDelay
timeout = setTimeout(() => {
const currentWord = wordsToAnimate[currentWordIndex] || ""
const graphemes = Array.from(currentWord)
switch (phase) {
case "typing":
if (currentCharIndex < graphemes.length) {
setDisplayedText(
graphemes.slice(0, currentCharIndex + 1).join("")
)
setCurrentCharIndex(currentCharIndex + 1)
} else {
if (hasMultipleWords || loop) {
const isLastWord =
currentWordIndex === wordsToAnimate.length - 1
if (!isLastWord || loop) {
setPhase("pause")
}
}
}
break
case "pause":
setPhase("deleting")
break
case "deleting":
if (currentCharIndex > 0) {
setDisplayedText(
graphemes.slice(0, currentCharIndex - 1).join("")
)
setCurrentCharIndex(currentCharIndex - 1)
} else {
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
setCurrentWordIndex(nextIndex)
setPhase("typing")
}
break
}
}, timeoutDelay)
}
return () => {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [
shouldStart,
phase,
currentCharIndex,
currentWordIndex,
displayedText,
wordsToAnimate,
hasMultipleWords,
loop,
typingSpeed,
deletingSpeed,
pauseDelay,
delay,
])
const currentWordGraphemes = Array.from(
wordsToAnimate[currentWordIndex] || ""
)
const isComplete =
!loop &&
currentWordIndex === wordsToAnimate.length - 1 &&
currentCharIndex >= currentWordGraphemes.length &&
phase !== "deleting"
const shouldShowCursor =
showCursor &&
!isComplete &&
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
const getCursorChar = () => {
switch (cursorStyle) {
case "block":
return "▌"
case "underscore":
return "_"
case "line":
default:
return "|"
}
}
return (
<MotionComponent
ref={elementRef}
className={cn(
"leading-20 tracking-[-0.02em]",
Component === "span" && "inline-block",
className
)}
{...props}
>
{displayedText}
{shouldShowCursor && (
<span
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
>
{getCursorChar()}
</span>
)}
</MotionComponent>
)
}
+150
View File
@@ -0,0 +1,150 @@
import React, { HTMLAttributes, useCallback, useMemo } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
interface WarpBackgroundProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
perspective?: number
beamsPerSide?: number
beamSize?: number
beamDelayMax?: number
beamDelayMin?: number
beamDuration?: number
gridColor?: string
}
const Beam = ({
width,
x,
delay,
duration,
}: {
width: string | number
x: string | number
delay: number
duration: number
}) => {
const hue = Math.floor(Math.random() * 360)
const ar = Math.floor(Math.random() * 10) + 1
return (
<motion.div
style={
{
"--x": `${x}`,
"--width": `${width}`,
"--aspect-ratio": `${ar}`,
"--background": `linear-gradient(hsl(${hue} 80% 60%), transparent)`,
} as React.CSSProperties
}
className={`absolute top-0 left-(--x) aspect-[1/var(--aspect-ratio)] w-(--width) [background:var(--background)]`}
initial={{ y: "100cqmax", x: "-50%" }}
animate={{ y: "-100%", x: "-50%" }}
transition={{
duration,
delay,
repeat: Infinity,
ease: "linear",
}}
/>
)
}
export const WarpBackground: React.FC<WarpBackgroundProps> = ({
children,
perspective = 100,
className,
beamsPerSide = 3,
beamSize = 5,
beamDelayMax = 3,
beamDelayMin = 0,
beamDuration = 3,
gridColor = "var(--border)",
...props
}) => {
const generateBeams = useCallback(() => {
const beams = []
const cellsPerSide = Math.floor(100 / beamSize)
const step = cellsPerSide / beamsPerSide
for (let i = 0; i < beamsPerSide; i++) {
const x = Math.floor(i * step)
const delay = Math.random() * (beamDelayMax - beamDelayMin) + beamDelayMin
beams.push({ x, delay })
}
return beams
}, [beamsPerSide, beamSize, beamDelayMax, beamDelayMin])
const topBeams = useMemo(() => generateBeams(), [generateBeams])
const rightBeams = useMemo(() => generateBeams(), [generateBeams])
const bottomBeams = useMemo(() => generateBeams(), [generateBeams])
const leftBeams = useMemo(() => generateBeams(), [generateBeams])
return (
<div className={cn("relative rounded border p-20", className)} {...props}>
<div
style={
{
"--perspective": `${perspective}px`,
"--grid-color": gridColor,
"--beam-size": `${beamSize}%`,
} as React.CSSProperties
}
className={
"@container-[size] pointer-events-none absolute top-0 left-0 size-full overflow-hidden [clipPath:inset(0)] perspective-(--perspective) transform-3d"
}
>
{/* top side */}
<div className="@container absolute z-20 h-[100cqmax] w-[100cqi] origin-[50%_0%] transform-[rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{topBeams.map((beam, index) => (
<Beam
key={`top-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
{/* bottom side */}
<div className="@container absolute top-full h-[100cqmax] w-[100cqi] origin-[50%_0%] transform-[rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{bottomBeams.map((beam, index) => (
<Beam
key={`bottom-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
{/* left side */}
<div className="@container absolute top-0 left-0 h-[100cqmax] w-[100cqh] origin-[0%_0%] transform-[rotate(90deg)_rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{leftBeams.map((beam, index) => (
<Beam
key={`left-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
{/* right side */}
<div className="@container absolute top-0 right-0 h-[100cqmax] w-[100cqh] origin-[100%_0%] transform-[rotate(-90deg)_rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{rightBeams.map((beam, index) => (
<Beam
key={`right-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
</div>
<div className="relative">{children}</div>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { areasService, type AreaItem } from "@/services/areas";
import { toast } from "sonner";
export const DEFAULT_AREAS = [
"Bate-papo",
"Imagens",
"Transcrição de Áudio",
"Geração de Áudio",
"Agente de Parecer",
"Outro",
];
export interface Prompt {
id: string;
titulo: string;
area: string;
area_id?: string;
descricao?: string;
conteudo?: string;
/** ISO ou string da API (ex.: "2026-04-14 13:58:44"). */
created_at?: string;
}
export type AreaDescriptions = Record<string, string>;
export type AreaIdByNome = Record<string, string>;
interface PromptsContextValue {
prompts: Prompt[];
setPrompts: React.Dispatch<React.SetStateAction<Prompt[]>>;
areas: string[];
setAreas: React.Dispatch<React.SetStateAction<string[]>>;
areaDescriptions: AreaDescriptions;
setAreaDescriptions: React.Dispatch<React.SetStateAction<AreaDescriptions>>;
areaItems: AreaItem[];
areaIdByNome: AreaIdByNome;
areasLoading: boolean;
areasError: string | null;
refreshAreas: () => Promise<void>;
}
const PromptsContext = createContext<PromptsContextValue | null>(null);
export function PromptsProvider({ children }: { children: React.ReactNode }) {
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [areas, setAreas] = useState<string[]>(() => [...DEFAULT_AREAS]);
const [areaDescriptions, setAreaDescriptions] = useState<AreaDescriptions>({});
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
const [areaIdByNome, setAreaIdByNome] = useState<AreaIdByNome>({});
const [areasLoading, setAreasLoading] = useState(true);
const [areasError, setAreasError] = useState<string | null>(null);
const refreshAreas = useCallback(async () => {
setAreasLoading(true);
setAreasError(null);
try {
const data = await areasService.listarTotal();
const nomes = data.map((a) => a.nome).sort((a, b) => a.localeCompare(b));
const descricoes = Object.fromEntries(data.map((a) => [a.nome, a.descricao ?? ""]));
const byNome = Object.fromEntries(data.map((a) => [a.nome, a.id]));
setAreas(nomes.length > 0 ? nomes : [...DEFAULT_AREAS]);
setAreaDescriptions(descricoes);
setAreaItems(data);
setAreaIdByNome(byNome);
} catch (err) {
const message = (err as { message?: string })?.message ?? "Erro ao carregar áreas";
console.error("[PromptsContext] areasService.listarTotal falhou:", err);
setAreasError(message);
toast.error("Não foi possível carregar as áreas. Verifique a conexão e a chave da API.");
} finally {
setAreasLoading(false);
}
}, []);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
return (
<PromptsContext.Provider value={{ prompts, setPrompts, areas, setAreas, areaDescriptions, setAreaDescriptions, areaItems, areaIdByNome, areasLoading, areasError, refreshAreas }}>
{children}
</PromptsContext.Provider>
);
}
export function usePrompts() {
const ctx = useContext(PromptsContext);
if (!ctx) throw new Error("usePrompts must be used within PromptsProvider");
return ctx;
}
+169 -1
View File
@@ -31,6 +31,12 @@
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--success: 142 71% 45%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 222 47% 11%;
--border: 214 32% 91%; --border: 214 32% 91%;
--input: 214 32% 91%; --input: 214 32% 91%;
--ring: 190 100% 45%; --ring: 190 100% 45%;
@@ -56,6 +62,11 @@
/* Animation speeds */ /* Animation speeds */
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); --transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
} }
.dark { .dark {
@@ -84,6 +95,12 @@
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 200 100% 95%; --destructive-foreground: 200 100% 95%;
--success: 142 71% 45%;
--success-foreground: 222 47% 5%;
--warning: 38 92% 50%;
--warning-foreground: 222 47% 5%;
--border: 222 30% 18%; --border: 222 30% 18%;
--input: 222 30% 18%; --input: 222 30% 18%;
--ring: 190 100% 50%; --ring: 190 100% 50%;
@@ -103,6 +120,22 @@
--gradient-subtle: linear-gradient(180deg, hsl(222 45% 8%) 0%, hsl(222 47% 5%) 100%); --gradient-subtle: linear-gradient(180deg, hsl(222 45% 8%) 0%, hsl(222 47% 5%) 100%);
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.3); --glow-cyan: 0 0 40px hsl(190 100% 50% / 0.3);
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.25); --glow-blue: 0 0 30px hsl(210 100% 50% / 0.25);
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
.theme {
--animate-shiny-text: shiny-text 8s infinite;
--animate-gradient: gradient 8s linear infinite;
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
--animate-shimmer-slide: shimmer-slide var(--speed) ease-in-out infinite alternate;
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;
--animate-rippling: rippling var(--duration) ease-out;
--animate-blink-cursor: blink-cursor 1.2s step-end infinite;
--animate-background-position-spin: background-position-spin 3000ms infinite alternate;
--animate-ripple: ripple var(--duration,2s) ease calc(var(--i, 0)*.2s) infinite;
} }
} }
@@ -137,7 +170,7 @@
@layer components { @layer components {
.glass-effect { .glass-effect {
@apply bg-card/40 backdrop-blur-xl border border-border/50; @apply bg-card/80 backdrop-blur-xl border border-border/50;
} }
.cyber-glow { .cyber-glow {
@@ -152,4 +185,139 @@
.gradient-text { .gradient-text {
@apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent; @apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent;
} }
.metric-card {
@apply bg-card rounded-xl p-5 border border-border shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/30;
}
.nav-item {
@apply flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full;
display: flex;
align-items: center;
text-decoration: none;
}
.nav-item svg,
.nav-item .w-5 {
flex-shrink: 0;
}
.nav-item span {
white-space: nowrap;
}
.nav-item-inactive {
@apply text-muted-foreground hover:text-foreground hover:bg-sidebar-accent;
}
.nav-item-active {
@apply text-primary bg-sidebar-accent font-medium;
}
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-connected {
@apply bg-success;
box-shadow: 0 0 8px hsl(var(--success) / 0.6);
animation: pulse 2s infinite;
}
.status-error {
@apply bg-destructive;
box-shadow: 0 0 8px hsl(var(--destructive) / 0.6);
animation: pulse 2s infinite;
}
.status-warning {
@apply bg-warning;
box-shadow: 0 0 8px hsl(var(--warning) / 0.6);
animation: pulse 2s infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@theme inline {
@keyframes shiny-text {
0%, 90%, 100% {
background-position: calc(-100% - var(--shiny-width)) 0;
}
30%, 60% {
background-position: calc(100% + var(--shiny-width)) 0;
}
}
@keyframes gradient {
to {
background-position: var(--bg-size, 300%) 0;
}
}
@keyframes rainbow {
0% {
background-position: 0%;
}
100% {
background-position: 200%;
}
}
@keyframes shimmer-slide {
to {
transform: translate(calc(100cqw - 100%), 0);
}
}
@keyframes spin-around {
0% {
transform: translateZ(0) rotate(0);
}
15%, 35% {
transform: translateZ(0) rotate(90deg);
}
65%, 85% {
transform: translateZ(0) rotate(270deg);
}
100% {
transform: translateZ(0) rotate(360deg);
}
}
@keyframes rippling {
0% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes blink-cursor {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes background-position-spin {
0% {
background-position: top center;
}
100% {
background-position: bottom center;
}
}
@keyframes ripple {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(0.9);
}
}
} }
+26
View File
@@ -0,0 +1,26 @@
import { Routes, Route } from "react-router-dom";
import { MainLayout } from "@/modules/intelligence-ia/components/layout/MainLayout";
import Index from "./pages/Index";
import Integrations from "./pages/Integrations";
import Financas from "./pages/Financas";
import MeuPerfil from "./pages/MeuPerfil";
import NotFound from "./pages/NotFound";
// App do módulo Intelligence IA - funciona como subpath
// Não precisa de BrowserRouter, QueryClientProvider, TooltipProvider, Toaster ou Sonner
// pois já são fornecidos pelo App principal
const IntelligenceIAApp = () => {
return (
<MainLayout>
<Routes>
<Route index element={<Index />} />
<Route path="integracoes" element={<Integrations />} />
<Route path="financas" element={<Financas />} />
<Route path="meu-perfil" element={<MeuPerfil />} />
<Route path="*" element={<NotFound />} />
</Routes>
</MainLayout>
);
};
export default IntelligenceIAApp;
Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };
@@ -0,0 +1,44 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: ReactNode;
status?: "success" | "warning" | "error";
className?: string;
}
export function MetricCard({
title,
value,
subtitle,
icon,
status,
className,
}: MetricCardProps) {
return (
<div className={cn("metric-card animate-fade-in", className)}>
<div className="flex items-start justify-between mb-3">
<div className="p-2.5 rounded-lg bg-primary/10 text-primary">
{icon}
</div>
{status && (
<span
className={cn("status-dot", {
"status-connected": status === "success",
"status-warning": status === "warning",
"status-error": status === "error",
})}
/>
)}
</div>
<p className="text-muted-foreground text-sm mb-1">{title}</p>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{subtitle && (
<p className="text-muted-foreground text-xs mt-1">{subtitle}</p>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
interface StatusBadgeProps {
status: "connected" | "error" | "disconnected" | "pending";
label?: string;
}
const statusConfig = {
connected: {
label: "Conectado",
className: "bg-success/10 text-success border-success/20",
},
error: {
label: "Erro",
className: "bg-destructive/10 text-destructive border-destructive/20",
},
disconnected: {
label: "Desconectado",
className: "bg-muted text-muted-foreground border-border",
},
pending: {
label: "Pendente",
className: "bg-warning/10 text-warning border-warning/20",
},
};
export function StatusBadge({ status, label }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border",
config.className
)}
>
<span
className={cn("w-1.5 h-1.5 rounded-full", {
"bg-success": status === "connected",
"bg-destructive": status === "error",
"bg-muted-foreground": status === "disconnected",
"bg-warning": status === "pending",
})}
/>
{label || config.label}
</span>
);
}
@@ -0,0 +1,291 @@
import { useState, useEffect } from "react";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import asanaLogo from "@/modules/intelligence-ia/assets/asana-logo.png";
interface Workspace {
id: string;
name: string;
}
interface User {
id: string;
name: string;
}
interface AsanaCardProps {
apiKey?: string;
workspaces?: Workspace[];
users?: User[];
selectedWorkspaceId?: string;
selectedUserId?: string;
loadingWorkspaces?: boolean;
loadingUsers?: boolean;
loadingIntegration?: boolean;
hasIntegration?: boolean;
saving?: boolean;
onSave?: (apiKey: string, workspaceId: string, userId: string) => void | Promise<void>;
onConfirmApiKey?: (apiKey: string) => void | Promise<void>;
onWorkspaceChange?: (workspaceId: string) => void | Promise<void>;
}
export function AsanaCard({
apiKey = "",
workspaces = [],
users = [],
selectedWorkspaceId = "",
selectedUserId = "",
loadingWorkspaces = false,
loadingUsers = false,
loadingIntegration = false,
hasIntegration = false,
saving = false,
onSave,
onConfirmApiKey,
onWorkspaceChange,
}: AsanaCardProps) {
const [showKey, setShowKey] = useState(false);
const [key, setKey] = useState(apiKey);
const [selectedWorkspace, setSelectedWorkspace] = useState(selectedWorkspaceId);
const [selectedUser, setSelectedUser] = useState(selectedUserId);
const [isApiKeyConfirmed, setIsApiKeyConfirmed] = useState(!!apiKey);
// Atualiza os valores quando as props mudam (carregamento inicial)
useEffect(() => {
if (apiKey) {
setKey(apiKey);
setIsApiKeyConfirmed(true);
}
}, [apiKey]);
useEffect(() => {
if (selectedWorkspaceId) {
setSelectedWorkspace(selectedWorkspaceId);
}
}, [selectedWorkspaceId]);
useEffect(() => {
if (selectedUserId) {
setSelectedUser(selectedUserId);
}
}, [selectedUserId]);
const handleKeyChange = (value: string) => {
setKey(value);
setSelectedWorkspace("");
setSelectedUser("");
setIsApiKeyConfirmed(false);
};
const handleConfirmApiKey = async () => {
if (key.length >= 5) {
setIsApiKeyConfirmed(true);
// Chama a função assíncrona para buscar workspaces
await onConfirmApiKey?.(key);
}
};
const handleWorkspaceChange = async (value: string) => {
setSelectedWorkspace(value);
setSelectedUser("");
// Chama a função assíncrona para buscar usuários
await onWorkspaceChange?.(value);
};
const handleSave = async () => {
await onSave?.(key, selectedWorkspace, selectedUser);
};
return (
<div className="metric-card space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<img src={asanaLogo} alt="Asana" className="w-6 h-6" />
</div>
<div>
<h3 className="text-foreground font-medium">Asana</h3>
<p className="text-muted-foreground text-sm">Gerencie tarefas e projetos</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
API Key / Token
</label>
<div className="relative">
{loadingIntegration ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</div>
) : (
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? "text" : "password"}
value={key}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder="Insira sua chave de API"
className="pr-10 bg-card border-border"
disabled={loadingWorkspaces}
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
disabled={loadingWorkspaces}
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
<Button
size="sm"
variant="outline"
onClick={handleConfirmApiKey}
disabled={key.length < 5 || isApiKeyConfirmed || loadingWorkspaces}
className="shrink-0"
>
{loadingWorkspaces ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Carregando...
</>
) : isApiKeyConfirmed ? (
"Confirmado"
) : (
"Confirmar"
)}
</Button>
</div>
)}
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Workspace
</label>
{loadingIntegration ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando integração...</span>
</div>
</div>
) : (
<div className="relative">
<Select
value={selectedWorkspace}
onValueChange={handleWorkspaceChange}
disabled={loadingWorkspaces || loadingIntegration}
>
<SelectTrigger className="bg-card border-border">
<SelectValue
placeholder={
loadingWorkspaces
? "Carregando workspaces..."
: workspaces.length === 0
? "Insira o token e clique em Confirmar"
: "Selecione um workspace"
}
/>
</SelectTrigger>
<SelectContent className="bg-card border-border">
{workspaces.map((ws) => (
<SelectItem key={ws.id} value={ws.id}>
{ws.name}
</SelectItem>
))}
</SelectContent>
</Select>
{loadingWorkspaces && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Usuário
</label>
{loadingUsers ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando usuários...</span>
</div>
</div>
) : (
<div className="relative">
<Select
value={selectedUser}
onValueChange={setSelectedUser}
disabled={loadingUsers || !selectedWorkspace}
>
<SelectTrigger className="bg-card border-border">
<SelectValue
placeholder={
!selectedWorkspace
? "Selecione um workspace primeiro"
: users.length === 0
? "Nenhum usuário encontrado"
: "Selecione um usuário"
}
/>
</SelectTrigger>
<SelectContent className="bg-card border-border">
{users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
onClick={handleSave}
disabled={!selectedWorkspace || !selectedUser || saving}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{hasIntegration ? "Salvando..." : "Criando..."}
</>
) : hasIntegration ? (
"Salvar"
) : (
"Criar Integração"
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import googleCalendarLogo from "@/modules/intelligence-ia/assets/google-calendar-logo.png";
export function GoogleCalendarCard() {
return (
<div className="metric-card space-y-4 opacity-60">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<img src={googleCalendarLogo} alt="Google Calendar" className="w-6 h-6 grayscale" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground font-medium">Google Calendar</h3>
<span className="text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full">
Em breve
</span>
</div>
<p className="text-muted-foreground text-sm">Sincronize reuniões e eventos</p>
</div>
</div>
<div className="space-y-3 pointer-events-none">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client ID
</label>
<Input
type="text"
disabled
placeholder="Insira o Client ID"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client Secret
</label>
<Input
type="password"
disabled
placeholder="Insira o Client Secret"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Nome da Agenda
</label>
<Input
type="text"
disabled
placeholder="Ex: Reuniões de Trabalho"
className="bg-card border-border"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
disabled
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Salvar
</Button>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { FileSpreadsheet } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function GoogleSheetsCard() {
return (
<div className="metric-card space-y-4 opacity-60">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<FileSpreadsheet className="w-6 h-6 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground font-medium">Google Sheets</h3>
<span className="text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full">
Em breve
</span>
</div>
<p className="text-muted-foreground text-sm">Gerencie planilhas e dados</p>
</div>
</div>
<div className="space-y-3 pointer-events-none">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client ID
</label>
<Input
type="text"
disabled
placeholder="Insira o Client ID"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client Secret
</label>
<Input
type="password"
disabled
placeholder="Insira o Client Secret"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Link da Planilha
</label>
<Input
type="text"
disabled
placeholder="Cole o link da planilha aqui"
className="bg-card border-border"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
disabled
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Salvar
</Button>
</div>
</div>
);
}
@@ -0,0 +1,127 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Plug,
Bot,
ChevronLeft,
Menu,
X,
DollarSign,
UserCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ title: "Home", path: "", icon: LayoutDashboard },
{ title: "Finanças", path: "financas", icon: DollarSign },
{ title: "Integrações", path: "integracoes", icon: Plug },
{ title: "Meu Perfil", path: "meu-perfil", icon: UserCircle },
];
export function AppSidebar() {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const SidebarContent = () => (
<>
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center cyber-glow">
<Bot className="w-5 h-5 text-primary" />
</div>
{!collapsed && (
<div className="animate-fade-in">
<h1 className="text-sidebar-foreground font-semibold text-lg">AI Agent</h1>
<p className="text-muted-foreground text-xs">Painel Admin</p>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === ""} // Apenas a Home precisa de match exato
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
"nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full",
isActive
? "text-primary bg-sidebar-accent font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
)
}
>
<item.icon className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
</NavLink>
))}
</nav>
{/* Collapse button - desktop only */}
<div className="hidden lg:block px-3 py-4 border-t border-sidebar-border">
<button
onClick={() => setCollapsed(!collapsed)}
className="nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full justify-center lg:justify-start text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
>
<ChevronLeft
className={cn(
"w-5 h-5 transition-transform duration-300 flex-shrink-0",
collapsed && "rotate-180"
)}
/>
{!collapsed && <span>Recolher</span>}
</button>
</div>
</>
);
return (
<>
{/* Mobile menu button */}
<button
onClick={() => setMobileOpen(true)}
className="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-lg bg-card border border-border shadow-sm"
>
<Menu className="w-5 h-5" />
</button>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-40"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={cn(
"lg:hidden fixed left-0 top-0 h-full w-64 bg-sidebar z-50 flex flex-col border-r border-sidebar-border transition-transform duration-300",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-4 right-4 p-2 rounded-lg hover:bg-sidebar-accent"
>
<X className="w-5 h-5" />
</button>
<SidebarContent />
</aside>
{/* Desktop sidebar */}
<aside
className={cn(
"hidden lg:flex flex-col h-screen bg-sidebar border-r border-sidebar-border transition-all duration-300 sticky top-0",
collapsed ? "w-[72px]" : "w-64"
)}
>
<SidebarContent />
</aside>
</>
);
}
@@ -0,0 +1,19 @@
import { ReactNode } from "react";
import { AppSidebar } from "./AppSidebar";
interface MainLayoutProps {
children: ReactNode;
}
export function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<main className="flex-1 overflow-auto">
<div className="p-4 lg:p-8 pt-16 lg:pt-8">
{children}
</div>
</main>
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Bot, Sparkles, Zap, Brain, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { GlobalFunctions } from "@/GlobalFunctions";
export default function Dashboard() {
const today = new Date();
const formattedDate = format(today, "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR });
const hour = today.getHours();
const getGreeting = () => {
if (hour < 12) return "Bom dia";
if (hour < 18) return "Boa tarde";
return "Boa noite";
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center px-4">
{/* Floating particles background effect */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-primary/30 rounded-full animate-pulse" />
<div className="absolute top-1/3 right-1/3 w-1 h-1 bg-primary/40 rounded-full animate-pulse delay-300" />
<div className="absolute bottom-1/3 left-1/3 w-1.5 h-1.5 bg-primary/20 rounded-full animate-pulse delay-500" />
<div className="absolute top-1/2 right-1/4 w-1 h-1 bg-primary/30 rounded-full animate-pulse delay-700" />
</div>
{/* AI Agent Avatar */}
<div className="relative mb-8 animate-fade-in">
{/* Outer glow ring */}
<div className="absolute inset-0 w-32 h-32 rounded-full bg-gradient-to-r from-primary/20 via-primary/10 to-primary/20 blur-xl animate-pulse" />
{/* Middle ring */}
<div className="relative w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 p-1 cyber-glow">
<div className="w-full h-full rounded-full bg-card border border-primary/30 flex items-center justify-center relative overflow-hidden">
{/* Inner gradient effect */}
<div className="absolute inset-0 bg-gradient-to-t from-primary/10 to-transparent" />
{/* Bot icon */}
<Bot className="w-14 h-14 text-primary relative z-10" />
{/* Scanning line effect */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/20 to-transparent h-8 animate-[slide-scan_2s_ease-in-out_infinite]" />
</div>
</div>
{/* Orbiting icons */}
<div className="absolute -top-2 -right-2 w-10 h-10 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-bounce">
<Sparkles className="w-5 h-5 text-yellow-500" />
</div>
<div className="absolute -bottom-1 -left-1 w-8 h-8 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-pulse">
<Zap className="w-4 h-4 text-primary" />
</div>
<div className="absolute top-1/2 -right-4 w-8 h-8 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-pulse delay-300">
<Brain className="w-4 h-4 text-primary" />
</div>
</div>
{/* Greeting */}
<div className="space-y-3 animate-fade-in" style={{ animationDelay: "0.2s" }}>
<h1 className="text-4xl md:text-5xl font-bold text-foreground">
{getGreeting()}! 👋
</h1>
<p className="text-xl text-muted-foreground">
Seu agente de IA está pronto para ajudar
</p>
</div>
{/* Date card */}
<div
className="mt-8 px-6 py-3 rounded-full bg-card/50 border border-border backdrop-blur-sm animate-fade-in"
style={{ animationDelay: "0.4s" }}
>
<p className="text-muted-foreground capitalize">
📅 {formattedDate}
</p>
</div>
{/* Status indicator */}
<div
className="mt-6 flex items-center gap-2 text-sm animate-fade-in"
style={{ animationDelay: "0.6s" }}
>
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-muted-foreground">Sistema operacional</span>
</div>
{/* WhatsApp Button */}
<Button
asChild
size="lg"
variant="outline"
className="mt-8 gap-2 border-[#25D366] text-[#25D366] hover:bg-[#25D366] hover:text-white hover:border-[#25D366] animate-fade-in"
style={{ animationDelay: "0.8s" }}
>
<a href="https://wa.me/5511914382960" target="_blank" rel="noopener noreferrer">
<MessageCircle className="w-5 h-5" />
Conversar com o Agente
</a>
</Button>
</div>
);
}
@@ -0,0 +1,777 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
DollarSign,
TrendingUp,
TrendingDown,
Building2,
User,
Receipt,
Filter,
Calendar,
Search,
CalendarIcon,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Badge } from "@/components/ui/badge";
import { personalAgent, FinancialIndicators, ExpenseItem, ExpensesResponse, ExpensesFilters, ExpenseCategory } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
import { toast } from "@/hooks/use-toast";
// Mapeamento de categoria_id para cores (cores padrão caso não tenha categoria)
const categoryColorMap: Record<number, string> = {
1: "bg-orange-500/10 text-orange-600 border-orange-500/20", // Alimentação
2: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20", // Combustível
3: "bg-purple-500/10 text-purple-600 border-purple-500/20", // Hospedagem
4: "bg-blue-500/10 text-blue-600 border-blue-500/20", // Estacionamento
5: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20", // Pedágio
6: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20", // Transporte
};
// Função auxiliar para obter informações da categoria
const getCategoryInfo = (categoriaId: number | undefined | null, categories: ExpenseCategory[]) => {
// Se não tem categoria_id válido, retorna padrão
if (categoriaId === undefined || categoriaId === null || categoriaId === 0) {
return {
label: "Outros",
color: "bg-gray-500/10 text-gray-600 border-gray-500/20",
};
}
// Converte para número caso venha como string
const id = typeof categoriaId === 'string' ? parseInt(categoriaId, 10) : categoriaId;
// Busca a categoria na lista carregada
const category = categories.find(cat => cat.id === id);
// Se encontrou a categoria, usa o nome dela
if (category) {
const color = categoryColorMap[id] || "bg-gray-500/10 text-gray-600 border-gray-500/20";
return {
label: category.nome,
color: color,
};
}
// Se não encontrou mas tem ID válido, usa o ID com cor padrão
const color = categoryColorMap[id] || "bg-gray-500/10 text-gray-600 border-gray-500/20";
return {
label: `Categoria ${id}`,
color: color,
};
};
function formatCurrency(value: number) {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
}
function formatDateTime(dateTimeString: string) {
// Formata "2026-01-14 10:38:57" para Date
const date = new Date(dateTimeString.replace(" ", "T"));
return {
date: date.toLocaleDateString("pt-BR"),
time: date.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }),
};
}
export default function Financas() {
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all"); // Agora será o ID da categoria ou "all"
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Estados para os indicadores financeiros da API
const [loadingIndicators, setLoadingIndicators] = useState(true);
const [indicators, setIndicators] = useState<FinancialIndicators | null>(null);
// Estados para as despesas da API
const [loadingExpenses, setLoadingExpenses] = useState(true);
const [expensesData, setExpensesData] = useState<ExpensesResponse | null>(null);
const [userEmail, setUserEmail] = useState<string>("");
// Estados para as categorias da API
const [loadingCategories, setLoadingCategories] = useState(true);
const [categories, setCategories] = useState<ExpenseCategory[]>([]);
// Carrega os indicadores financeiros da API
useEffect(() => {
const loadFinancialIndicators = async () => {
try {
setLoadingIndicators(true);
// Obtém o email do usuário logado
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
if (!userEmail) {
console.error("Financas: Não foi possível obter email do usuário");
toast({
title: "Erro",
description: "Email do usuário não encontrado. Por favor, faça login novamente.",
variant: "destructive",
});
setLoadingIndicators(false);
return;
}
// Busca os indicadores financeiros da API
const response = await personalAgent.getFinancialIndicators(userEmail);
if (response.success) {
setIndicators(response);
} else {
throw new Error(response.message || "Erro ao carregar indicadores financeiros");
}
} catch (error: unknown) {
console.error("Erro ao carregar indicadores financeiros:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar indicadores financeiros.",
variant: "destructive",
});
} finally {
setLoadingIndicators(false);
}
};
loadFinancialIndicators();
}, []);
// Função auxiliar para obter o email do usuário
const getUserEmail = async (): Promise<string | null> => {
let email = GlobalFunctions.getUsuarioLogado().email;
if (!email) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
email = transferEmail as string;
}
}
if (!email) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
email = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
return email || null;
};
// Carrega as despesas da API
const loadExpenses = useCallback(async (page: number = 1) => {
const emailToUse = userEmail || await getUserEmail();
if (!emailToUse) {
setLoadingExpenses(false);
return;
}
if (!userEmail && emailToUse) {
setUserEmail(emailToUse);
}
try {
setLoadingExpenses(true);
// Prepara filtros para a API
const filters: ExpensesFilters = {
page,
per_page: itemsPerPage,
};
if (searchTerm.trim()) {
filters.descricao = searchTerm.trim();
}
if (categoryFilter !== "all") {
// Usa o ID da categoria diretamente
const categoriaId = parseInt(categoryFilter, 10);
if (!isNaN(categoriaId)) {
filters.categoria_id = categoriaId;
}
}
if (startDate) {
filters.data_inicial = format(startDate, "yyyy-MM-dd");
}
if (endDate) {
filters.data_final = format(endDate, "yyyy-MM-dd");
}
const response = await personalAgent.getExpenses(emailToUse, filters);
if (response.success) {
// Garante que a resposta tenha estrutura válida mesmo quando não há dados
const safeResponse: ExpensesResponse = {
success: response.success,
total_registros: response.total_registros || 0,
total_paginas: response.total_paginas || 0,
per_page: response.per_page || itemsPerPage,
pagina_atual: response.pagina_atual || page,
data: Array.isArray(response.data)
? response.data.filter((item) => item && item.id && Object.keys(item).length > 0)
: [],
};
setExpensesData(safeResponse);
} else {
throw new Error("Erro ao carregar despesas");
}
} catch (error: unknown) {
console.error("Erro ao carregar despesas:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar despesas.",
variant: "destructive",
});
} finally {
setLoadingExpenses(false);
}
}, [userEmail, searchTerm, categoryFilter, startDate, endDate, itemsPerPage]);
// Carrega o email do usuário e as categorias na montagem do componente
useEffect(() => {
const initData = async () => {
// Carrega email do usuário
const email = await getUserEmail();
if (email) {
setUserEmail(email);
}
// Carrega categorias
try {
setLoadingCategories(true);
const categoriesData = await personalAgent.getCategories();
setCategories(categoriesData);
} catch (error) {
console.error("Erro ao carregar categorias:", error);
toast({
title: "Aviso",
description: "Não foi possível carregar as categorias. Usando categorias padrão.",
variant: "default",
});
} finally {
setLoadingCategories(false);
}
};
initData();
}, []);
// Debounce para busca de descrição
useEffect(() => {
const timeoutId = setTimeout(() => {
if (userEmail) {
setCurrentPage(1); // Reseta para página 1 ao buscar
loadExpenses(1);
}
}, 500); // Aguarda 500ms após parar de digitar
return () => clearTimeout(timeoutId);
}, [searchTerm, userEmail, loadExpenses]);
// Carrega despesas quando os filtros ou página mudam (exceto searchTerm que tem debounce)
useEffect(() => {
if (userEmail) {
loadExpenses(currentPage);
}
}, [currentPage, typeFilter, categoryFilter, startDate, endDate, userEmail, loadExpenses]);
// Calculate totals - usa dados da API se disponível
const totals = useMemo(() => {
if (indicators && indicators.success) {
return {
total: parseFloat(indicators.total_despesas) || 0,
corporate: parseFloat(indicators.total_corporativo) || 0,
personal: parseFloat(indicators.total_pessoal) || 0,
};
}
return { total: 0, personal: 0, corporate: 0 };
}, [indicators]);
// Filtra despesas por tipo (se necessário, já que a API pode não filtrar por tipo)
const filteredExpenses = useMemo(() => {
if (!expensesData || !expensesData.data || !Array.isArray(expensesData.data)) {
return [];
}
// Filtra objetos vazios ou inválidos
let expenses = expensesData.data.filter((expense) => {
// Verifica se o expense é válido e tem propriedades necessárias
// categoria_id pode ser opcional, então não validamos ele aqui
return expense &&
typeof expense === 'object' &&
expense.id &&
expense.descricao &&
expense.data_hora &&
expense.valor;
});
// Filtra por tipo se necessário (a API pode não ter esse filtro)
if (typeFilter !== "all") {
expenses = expenses.filter((expense) => expense.tipo === typeFilter);
}
return expenses;
}, [expensesData, typeFilter]);
const clearFilters = () => {
setSearchTerm("");
setTypeFilter("all");
setCategoryFilter("all");
setStartDate(undefined);
setEndDate(undefined);
setCurrentPage(1);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold gradient-text">Finanças</h1>
<p className="text-muted-foreground mt-1">
Análise de despesas pessoais e corporativas identificadas pelo agente
</p>
</div>
{/* Indicators */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total de Despesas</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.total)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-primary" />
</div>
</div>
</CardContent>
</Card>
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Despesas Corporativas</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.corporate)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-secondary/10 flex items-center justify-center">
<Building2 className="w-6 h-6 text-secondary" />
</div>
</div>
</CardContent>
</Card>
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Despesas Pessoais</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.personal)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-warning/10 flex items-center justify-center">
<User className="w-6 h-6 text-warning" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="w-4 h-4" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
{/* First row: Search + Date filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Buscar por descrição..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="pl-9"
/>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-[160px] justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "dd/MM/yyyy") : "Data inicial"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={startDate}
onSelect={(date) => {
setStartDate(date);
setCurrentPage(1);
}}
locale={ptBR}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-[160px] justify-start text-left font-normal",
!endDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "dd/MM/yyyy") : "Data final"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={endDate}
onSelect={(date) => {
setEndDate(date);
setCurrentPage(1);
}}
locale={ptBR}
disabled={(date) => startDate ? date < startDate : false}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
</div>
{/* Second row: Type + Category + Clear */}
<div className="flex flex-col md:flex-row gap-3">
<Select
value={typeFilter}
onValueChange={(value) => {
setTypeFilter(value);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os tipos</SelectItem>
<SelectItem value="pessoal">Pessoal</SelectItem>
<SelectItem value="corporativo">Corporativo</SelectItem>
</SelectContent>
</Select>
<Select
value={categoryFilter}
onValueChange={(value) => {
setCategoryFilter(value);
setCurrentPage(1);
}}
disabled={loadingCategories}
>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder={loadingCategories ? "Carregando..." : "Categoria"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas categorias</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.nome}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={clearFilters}>
Limpar filtros
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Receipt className="w-4 h-4" />
Registro de Despesas
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">
<div className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
Data/Hora
</div>
</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-[120px]">Tipo</TableHead>
<TableHead className="w-[130px]">Categoria</TableHead>
<TableHead className="w-[120px] text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingExpenses ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Carregando despesas...</span>
</div>
</TableCell>
</TableRow>
) : filteredExpenses.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
{expensesData && expensesData.total_registros === 0
? "Nenhuma despesa encontrada."
: "Nenhuma despesa encontrada com os filtros aplicados."}
</TableCell>
</TableRow>
) : (
filteredExpenses.map((expense) => {
// Validação adicional para garantir que o expense é válido
if (!expense || !expense.id || !expense.data_hora || !expense.descricao || !expense.valor) {
return null;
}
try {
const { date, time } = formatDateTime(expense.data_hora);
// Usa categoria_id diretamente (pode ser number, undefined ou null)
const categoriaId = expense.categoria_id;
const categoryInfo = getCategoryInfo(categoriaId, categories);
const valor = parseFloat(expense.valor) || 0;
return (
<TableRow key={expense.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{date}</span>
<span className="text-xs text-muted-foreground">{time}</span>
</div>
</TableCell>
<TableCell>
<span className="font-medium">{expense.descricao || "Sem descrição"}</span>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
expense.tipo === "corporativo"
? "bg-secondary/10 text-secondary border-secondary/20"
: "bg-warning/10 text-warning border-warning/20"
}
>
{expense.tipo === "corporativo" ? (
<Building2 className="w-3 h-3 mr-1" />
) : (
<User className="w-3 h-3 mr-1" />
)}
{expense.tipo === "corporativo" ? "Corp." : "Pessoal"}
</Badge>
</TableCell>
<TableCell>
{categoryInfo ? (
<Badge
variant="outline"
className={categoryInfo.color}
>
{categoryInfo.label}
</Badge>
) : (
<Badge
variant="outline"
className="bg-gray-500/10 text-gray-600 border-gray-500/20"
>
Sem categoria
</Badge>
)}
</TableCell>
<TableCell className="text-right font-semibold">
{formatCurrency(valor)}
</TableCell>
</TableRow>
);
} catch (error) {
console.error("Erro ao renderizar despesa:", error, expense);
return null;
}
}).filter(Boolean) // Remove nulls do array
)}
</TableBody>
</Table>
{/* Pagination */}
{expensesData && expensesData.total_paginas > 0 && expensesData.total_paginas > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Mostrando {(currentPage - 1) * itemsPerPage + 1} a{" "}
{Math.min(currentPage * itemsPerPage, expensesData.total_registros)} de{" "}
{expensesData.total_registros} registros
</p>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
}}
className={
currentPage === 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
{Array.from({ length: expensesData.total_paginas }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => {
if (currentPage < expensesData.total_paginas) {
setCurrentPage(currentPage + 1);
}
}}
className={
currentPage === expensesData.total_paginas
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,7 @@
import Dashboard from "./Dashboard";
const Index = () => {
return <Dashboard />;
};
export default Index;
@@ -0,0 +1,310 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { GoogleCalendarCard } from "@/modules/intelligence-ia/components/integrations/GoogleCalendarCard";
import { GoogleSheetsCard } from "@/modules/intelligence-ia/components/integrations/GoogleSheetsCard";
import { AsanaCard } from "@/modules/intelligence-ia/components/integrations/AsanaCard";
import { asanaService } from "@/services/asana";
import { personalAgent } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
interface Workspace {
id: string;
name: string;
}
interface User {
id: string;
name: string;
}
export default function Integrations() {
const [asanaWorkspaces, setAsanaWorkspaces] = useState<Workspace[]>([]);
const [asanaUsers, setAsanaUsers] = useState<User[]>([]);
const [loadingAsanaWorkspaces, setLoadingAsanaWorkspaces] = useState(false);
const [loadingAsanaUsers, setLoadingAsanaUsers] = useState(false);
const [asanaToken, setAsanaToken] = useState<string>("");
const [asanaApiKey, setAsanaApiKey] = useState<string>("");
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [loadingIntegration, setLoadingIntegration] = useState(true);
const [hasIntegration, setHasIntegration] = useState(false);
const [integrationId, setIntegrationId] = useState<string>("");
const [userId, setUserId] = useState<string>("");
const [saving, setSaving] = useState(false);
const handleAsanaSave = async (apiKey: string, workspaceId: string, usuarioAsanaId: string) => {
if (!apiKey || !workspaceId || !usuarioAsanaId) {
toast.error("Preencha todos os campos obrigatórios");
return;
}
// Encontra o nome do workspace e do usuário selecionado
const workspace = asanaWorkspaces.find(w => w.id === workspaceId);
const user = asanaUsers.find(u => u.id === usuarioAsanaId);
if (!workspace || !user) {
toast.error("Workspace ou usuário não encontrado");
return;
}
try {
setSaving(true);
if (hasIntegration && integrationId) {
// Atualiza integração existente
await asanaService.updateIntegration(integrationId, {
api_key: apiKey,
workspace_gid: workspaceId,
workspace_nome: workspace.name,
usuario_asana_gid: usuarioAsanaId,
usuario_asana_nome: user.name,
});
toast.success("Integração do Asana atualizada com sucesso!");
} else {
// Cria nova integração
if (!userId) {
toast.error("ID do usuário não encontrado");
return;
}
const response = await asanaService.createIntegration({
user_id: userId,
api_key: apiKey,
workspace_gid: workspaceId,
workspace_nome: workspace.name,
usuario_asana_gid: usuarioAsanaId,
usuario_asana_nome: user.name,
});
if (response.success && response.integracao_id) {
setIntegrationId(response.integracao_id);
setHasIntegration(true);
}
toast.success("Integração do Asana criada com sucesso!");
}
} catch (error: unknown) {
console.error("Erro ao salvar integração do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao salvar integração do Asana";
toast.error(errorMessage);
} finally {
setSaving(false);
}
};
const handleAsanaApiKeyConfirm = async (apiKey: string) => {
if (!apiKey || apiKey.trim().length === 0) {
toast.error("Insira um token válido do Asana");
return;
}
try {
setLoadingAsanaWorkspaces(true);
// Busca workspaces da API do Asana
const workspaces = await asanaService.getWorkspaces(apiKey);
if (workspaces && workspaces.length > 0) {
setAsanaWorkspaces(workspaces);
setAsanaUsers([]);
setAsanaToken(apiKey); // Armazena o token para usar na busca de usuários
toast.success(`${workspaces.length} workspace(s) encontrado(s)`);
} else {
setAsanaWorkspaces([]);
setAsanaUsers([]);
setAsanaToken("");
toast.warning("Nenhum workspace encontrado para este token");
}
} catch (error: unknown) {
console.error("Erro ao buscar workspaces do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao buscar workspaces do Asana. Verifique se o token está correto.";
toast.error(errorMessage);
setAsanaWorkspaces([]);
setAsanaUsers([]);
setAsanaToken(""); // Limpa o token em caso de erro
} finally {
setLoadingAsanaWorkspaces(false);
}
};
const handleAsanaWorkspaceChange = async (workspaceId: string) => {
setSelectedWorkspaceId(workspaceId);
if (!workspaceId || !asanaToken) {
setAsanaUsers([]);
setSelectedUserId("");
return;
}
try {
setLoadingAsanaUsers(true);
// Busca usuários do workspace da API do Asana
const users = await asanaService.getUsers(asanaToken, workspaceId);
if (users && users.length > 0) {
setAsanaUsers(users);
// Não mostra toast ao selecionar workspace (só quando confirmar token)
} else {
setAsanaUsers([]);
setSelectedUserId("");
}
} catch (error: unknown) {
console.error("Erro ao buscar usuários do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao buscar usuários do Asana. Verifique se o token está correto.";
toast.error(errorMessage);
setAsanaUsers([]);
setSelectedUserId("");
} finally {
setLoadingAsanaUsers(false);
}
};
// Carrega a integração do Asana ao montar o componente
useEffect(() => {
const loadAsanaIntegration = async () => {
try {
setLoadingIntegration(true);
// Obtém o ID do usuário do perfil
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
if (!userEmail) {
setLoadingIntegration(false);
return;
}
// Busca o perfil do usuário para obter o ID
const userProfile = await personalAgent.getUserProfile(userEmail);
if (!userProfile.success || !userProfile.id) {
setLoadingIntegration(false);
return;
}
// Armazena o ID do usuário para usar ao criar integração
setUserId(userProfile.id);
// Busca a integração do Asana
const integration = await asanaService.getIntegration(userProfile.id);
if (integration && integration.success) {
// Marca que há integração existente
setHasIntegration(true);
// Armazena o ID da integração (pode vir como integracao_id ou id)
if (integration.integracao_id) {
setIntegrationId(integration.integracao_id);
} else if (integration.id) {
setIntegrationId(integration.id);
}
// Preenche os dados da integração
if (integration.api_key) {
setAsanaApiKey(integration.api_key);
setAsanaToken(integration.api_key);
// Busca workspaces com o token
try {
const workspaces = await asanaService.getWorkspaces(integration.api_key);
setAsanaWorkspaces(workspaces);
// Se tem workspace_gid, seleciona e busca usuários
if (integration.workspace_gid) {
setSelectedWorkspaceId(integration.workspace_gid);
// Busca usuários do workspace
const users = await asanaService.getUsers(integration.api_key, integration.workspace_gid);
setAsanaUsers(users);
// Se tem usuario_asana_gid, seleciona
if (integration.usuario_asana_gid) {
setSelectedUserId(integration.usuario_asana_gid);
}
}
} catch (error) {
console.error("Erro ao carregar workspaces/usuários:", error);
// Continua mesmo se não conseguir carregar workspaces
}
}
} else {
// Não há integração, marca como false
setHasIntegration(false);
}
} catch (error: unknown) {
console.error("Erro ao carregar integração do Asana:", error);
// Não mostra erro para o usuário, apenas deixa os campos vazios
} finally {
setLoadingIntegration(false);
}
};
loadAsanaIntegration();
}, []);
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold text-foreground mb-1">
Integrações
</h1>
<p className="text-muted-foreground">
Configure as conexões do seu agente com serviços externos
</p>
</div>
{/* Integrations Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<GoogleCalendarCard />
<GoogleSheetsCard />
<AsanaCard
apiKey={asanaApiKey}
workspaces={asanaWorkspaces}
users={asanaUsers}
selectedWorkspaceId={selectedWorkspaceId}
selectedUserId={selectedUserId}
onSave={handleAsanaSave}
onConfirmApiKey={handleAsanaApiKeyConfirm}
onWorkspaceChange={handleAsanaWorkspaceChange}
loadingWorkspaces={loadingAsanaWorkspaces}
loadingUsers={loadingAsanaUsers}
loadingIntegration={loadingIntegration}
hasIntegration={hasIntegration}
saving={saving}
/>
</div>
</div>
);
}
@@ -0,0 +1,500 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { User, Mail, Phone, Save, Bell, Loader2, Plus } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { personalAgent, UserProfile } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
const formatPhoneNumber = (value: string) => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 2) {
return numbers;
} else if (numbers.length <= 7) {
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
} else if (numbers.length <= 11) {
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(7)}`;
}
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(7, 11)}`;
};
const MeuPerfil = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [userExists, setUserExists] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [userId, setUserId] = useState<string>("");
const [nomeCompleto, setNomeCompleto] = useState("");
const [email, setEmail] = useState("");
const [whatsapp, setWhatsapp] = useState("");
const [receberLembretes, setReceberLembretes] = useState(true);
// Estados para o modal de criação
const [createNome, setCreateNome] = useState("");
const [createWhatsapp, setCreateWhatsapp] = useState("");
const [createFollowup, setCreateFollowup] = useState(true);
const [creating, setCreating] = useState(false);
// Verifica se o usuário está logado e carrega os dados
useEffect(() => {
const checkAuthAndLoad = async () => {
// Verifica se há token no sessionStorage primeiro
const jsonUsuario = sessionStorage.getItem('usuarioLogado');
if (!jsonUsuario) {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
return;
}
// Tenta obter o email do usuário logado
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta fazer refresh do token
if (!userEmail) {
try {
await GlobalFunctions.getToken();
// Aguarda um pouco para o refresh ser processado (se necessário)
await new Promise(resolve => setTimeout(resolve, 1000));
// Verifica novamente após refresh
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("MeuPerfil: Erro ao obter token:", error);
}
}
// Se ainda não tem email após tentar refresh, verifica se o token existe
// Se o token existe mas não tem email, pode ser problema na decodificação
if (!userEmail) {
const usuarioData = GlobalFunctions.getUsuarioLogado();
// Se não tem email mas tem token no sessionStorage, tenta carregar mesmo assim
// O loadUserProfile vai tratar o erro adequadamente se o email for necessário
// Só redireciona se o token estiver completamente inválido (sem UID e sem EID)
if (!usuarioData.email && usuarioData.UID === "0" && usuarioData.EID === "0") {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
return;
}
}
// Se passou na verificação (ou tem token válido), carrega o perfil
loadUserProfile();
};
checkAuthAndLoad();
}, []);
const loadUserProfile = async () => {
try {
setLoading(true);
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("MeuPerfil: Erro ao fazer refresh no loadUserProfile:", error);
}
}
if (!userEmail) {
console.error("MeuPerfil: Não foi possível obter email do usuário");
toast({
title: "Erro",
description: "Email do usuário não encontrado. Por favor, faça login novamente.",
variant: "destructive",
});
setLoading(false);
// Não redireciona aqui, deixa o usuário ver o erro
return;
}
setEmail(userEmail);
const response = await personalAgent.getUserProfile(userEmail);
if (response.success && response.id) {
// Usuário existe
setUserExists(true);
setUserId(response.id);
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
} else {
// Usuário não existe
setUserExists(false);
}
} catch (error: unknown) {
console.error("Erro ao carregar perfil:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("não está cadastrado")) {
setUserExists(false);
} else {
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar perfil do usuário.",
variant: "destructive",
});
}
} finally {
setLoading(false);
}
};
const handleWhatsappChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setWhatsapp(formatted);
};
const handleCreateWhatsappChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setCreateWhatsapp(formatted);
};
const handleSave = async () => {
if (!userExists || !userId) {
toast({
title: "Erro",
description: "Usuário não encontrado. Por favor, crie uma conta primeiro.",
variant: "destructive",
});
return;
}
try {
setSaving(true);
const response = await personalAgent.updateUser(userId, {
nome: nomeCompleto,
whatsapp: whatsapp,
followup: receberLembretes,
});
if (response.success) {
toast({
title: "Perfil atualizado",
description: "Suas informações foram salvas com sucesso.",
});
// Atualiza os dados locais
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
} else {
throw new Error(response.message || "Erro ao atualizar perfil");
}
} catch (error: unknown) {
console.error("Erro ao salvar:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro ao salvar",
description: errorMessage || "Erro ao atualizar perfil. Tente novamente.",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const handleCreateUser = async () => {
if (!createNome.trim()) {
toast({
title: "Erro",
description: "Nome é obrigatório.",
variant: "destructive",
});
return;
}
if (!createWhatsapp.trim()) {
toast({
title: "Erro",
description: "WhatsApp é obrigatório.",
variant: "destructive",
});
return;
}
try {
setCreating(true);
const response = await personalAgent.createUser({
nome: createNome.trim(),
email: email,
whatsapp: createWhatsapp,
followup: createFollowup,
});
if (response.success && response.id) {
toast({
title: "Conta criada",
description: "Sua conta foi criada com sucesso!",
});
setUserExists(true);
setUserId(response.id);
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
setShowCreateModal(false);
// Limpa os campos do modal
setCreateNome("");
setCreateWhatsapp("");
setCreateFollowup(true);
} else {
throw new Error(response.message || "Erro ao criar conta");
}
} catch (error: unknown) {
console.error("Erro ao criar usuário:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro ao criar conta",
description: errorMessage || "Erro ao criar conta. Tente novamente.",
variant: "destructive",
});
} finally {
setCreating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground">Carregando perfil...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Meu Perfil</h1>
<p className="text-muted-foreground mt-1">
Gerencie suas informações pessoais
</p>
</div>
{!userExists ? (
<Card className="max-w-2xl">
<CardContent className="py-12 text-center">
<div className="space-y-4">
<User className="w-16 h-16 mx-auto text-muted-foreground" />
<div>
<h3 className="text-xl font-semibold mb-2">
Você ainda não possui uma conta
</h3>
<p className="text-muted-foreground mb-6">
Crie sua conta para começar a usar o agente pessoal de IA
</p>
</div>
<Button onClick={() => setShowCreateModal(true)} size="lg">
<Plus className="w-4 h-4 mr-2" />
Criar Conta
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
Informações Pessoais
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="nomeCompleto" className="flex items-center gap-2">
<User className="w-4 h-4" />
Nome Completo
</Label>
<Input
id="nomeCompleto"
value={nomeCompleto}
onChange={(e) => setNomeCompleto(e.target.value)}
placeholder="Digite seu nome completo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="w-4 h-4" />
E-mail
</Label>
<Input
id="email"
type="email"
value={email}
readOnly
className="bg-muted cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
O e-mail não pode ser alterado
</p>
</div>
<div className="space-y-2">
<Label htmlFor="whatsapp" className="flex items-center gap-2">
<Phone className="w-4 h-4" />
WhatsApp
</Label>
<Input
id="whatsapp"
value={whatsapp}
onChange={handleWhatsappChange}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="receberLembretes" className="flex items-center gap-2 cursor-pointer">
<Bell className="w-4 h-4" />
Receber Follow up e lembretes
</Label>
<p className="text-xs text-muted-foreground">
Receba notificações sobre eventos e tarefas
</p>
</div>
<Switch
id="receberLembretes"
checked={receberLembretes}
onCheckedChange={setReceberLembretes}
/>
</div>
<Button
onClick={handleSave}
className="w-full sm:w-auto"
disabled={saving}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Salvando...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Salvar Alterações
</>
)}
</Button>
</CardContent>
</Card>
)}
{/* Modal de Criação de Conta */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar Conta</DialogTitle>
<DialogDescription>
Preencha os dados abaixo para criar sua conta no agente pessoal de IA
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="createNome">Nome Completo</Label>
<Input
id="createNome"
value={createNome}
onChange={(e) => setCreateNome(e.target.value)}
placeholder="Digite seu nome completo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="createEmail">E-mail</Label>
<Input
id="createEmail"
type="email"
value={email}
readOnly
className="bg-muted cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
O e-mail é baseado na sua conta logada
</p>
</div>
<div className="space-y-2">
<Label htmlFor="createWhatsapp">WhatsApp</Label>
<Input
id="createWhatsapp"
value={createWhatsapp}
onChange={handleCreateWhatsappChange}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="createFollowup" className="cursor-pointer">
Receber Follow up e lembretes
</Label>
<p className="text-xs text-muted-foreground">
Receba notificações sobre eventos e tarefas
</p>
</div>
<Switch
id="createFollowup"
checked={createFollowup}
onCheckedChange={setCreateFollowup}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateModal(false)}
disabled={creating}
>
Cancelar
</Button>
<Button onClick={handleCreateUser} disabled={creating}>
{creating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Criando...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Criar Conta
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default MeuPerfil;
@@ -0,0 +1,24 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;
+73 -42
View File
@@ -1,57 +1,88 @@
import { useState } from "react";
import { Layout } from "@/components/Layout"; import { Layout } from "@/components/Layout";
import { ChatView } from "@/components/chat/ChatView"; import { ChatView } from "@/components/chat/ChatView";
import { ImageView } from "@/components/images/ImageView"; import { ImageView } from "@/components/images/ImageView";
import { TranscriptionView } from "@/components/audio/TranscriptionView"; import { TranscriptionView } from "@/components/audio/TranscriptionView";
import { GenerationView } from "@/components/audio/GenerationView"; import { GenerationView } from "@/components/audio/GenerationView";
import { BotView } from "@/components/bots/BotView"; import { PromptsView } from "@/components/prompts/PromptsView";
import { BotChat } from "@/components/bots/BotChat"; import { PromptsFormView } from "../components/prompts/PromptsFormView";
import { AgentView } from "@/components/agent/AgentView"; import { AreasView } from "@/components/areas/AreasView";
import { Navigate, Route, Routes } from "react-router-dom"; import { ParecerJuridicoView } from "@/components/parecer-juridico/ParecerJuridicoView";
import { ParecerJuridicoFormView } from "@/components/parecer-juridico/ParecerJuridicoFormView";
import { ParecerJuridicoDetailView } from "@/components/parecer-juridico/ParecerJuridicoDetailView";
import { PromptsProvider } from "@/contexts/PromptsContext";
import { Navigate, Route, Routes, useLocation, useNavigate, Outlet } from "react-router-dom";
import React from "react";
import { GlobalFunctions } from "@/GlobalFunctions";
interface Bot { type TabType = "chat" | "images" | "transcription" | "generation" | "prompts" | "parecerJuridico" | "areas";
id: string;
name: string; const PATH_TO_TAB: Record<string, TabType> = {
prompt: string; "/codex/bate-papo": "chat",
model: string; "/codex/imagens": "images",
"/codex/transcricao-audio": "transcription",
"/codex/geracao-audio": "generation",
"/codex/parecer-juridico": "parecerJuridico",
"/codex/areas": "areas",
};
function getActiveTabFromPath(pathname: string): TabType {
if (pathname.includes("/prompts")) return "prompts";
if (pathname.includes("/parecer-juridico")) return "parecerJuridico";
const tab = PATH_TO_TAB[pathname];
if (tab) return tab;
return "chat";
}
const TAB_TO_PATH: Record<TabType, string> = {
chat: "/codex/bate-papo",
images: "/codex/imagens",
transcription: "/codex/transcricao-audio",
generation: "/codex/geracao-audio",
prompts: "/codex/prompts",
parecerJuridico: "/codex/parecer-juridico",
areas: "/codex/areas",
};
function CodexLayout() {
const location = useLocation();
const navigate = useNavigate();
const activeTab = getActiveTabFromPath(location.pathname);
const onTabChange = (tab: TabType) => {
navigate(TAB_TO_PATH[tab]);
};
return (
<Layout activeTab={activeTab} onTabChange={onTabChange}>
<Outlet />
</Layout>
);
} }
const Index = () => { const Index = () => {
const [activeView, setActiveView] = useState<"chat" | "images" | "transcription" | "generation" | "bots" | "agent">("chat"); React.useEffect(() => {
const [activeBotChat, setActiveBotChat] = useState<Bot | null>(null); if (!GlobalFunctions.isUsuarioLogado()) window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
}, []);
const handleStartBotChat = (bot: Bot) => {
setActiveBotChat(bot);
};
const handleBackFromBotChat = () => {
setActiveBotChat(null);
};
return (<Routes>
<Route path="" element={<Navigate to={`/404`} replace />} />
<Route path="codex">
<Route path="" element={
<Layout activeTab={activeView} onTabChange={setActiveView}>
{activeView === "chat" && <ChatView />}
{activeView === "images" && <ImageView />}
{activeView === "transcription" && <TranscriptionView />}
{activeView === "generation" && <GenerationView />}
{activeView === "bots" && (
activeBotChat ? (
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
) : (
<BotView onStartChat={handleStartBotChat} />
)
)}
{activeView === "agent" && <AgentView />}
</Layout>
} />
return (
<Routes>
<Route path="" element={<Navigate to="/404" replace />} />
<Route path="codex" element={<PromptsProvider><CodexLayout /></PromptsProvider>}>
<Route index element={<Navigate to="bate-papo" replace />} />
<Route path="bate-papo" element={<ChatView />} />
<Route path="imagens" element={<ImageView />} />
<Route path="transcricao-audio" element={<TranscriptionView />} />
<Route path="geracao-audio" element={<GenerationView />} />
<Route path="parecer-juridico" element={<ParecerJuridicoView />} />
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
<Route path="prompts" element={<PromptsView />} />
<Route path="prompts/novo" element={<PromptsFormView />} />
<Route path="prompts/:id" element={<PromptsFormView />} />
<Route path="areas" element={<AreasView />} />
</Route> </Route>
<Route path="*" element={<Navigate to={`/404`} replace />} /> <Route path="*" element={<Navigate to="/404" replace />} />
</Routes> </Routes>
); );
}; };
+75 -243
View File
@@ -1,9 +1,6 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api'; import { apiService } from './api';
/**
* Interface para resposta do POST de parecer técnico
*/
export interface CreateOpinionResponse { export interface CreateOpinionResponse {
success: boolean; success: boolean;
id?: string; id?: string;
@@ -11,9 +8,6 @@ export interface CreateOpinionResponse {
file_url_melhoria?: string; file_url_melhoria?: string;
} }
/**
* Interface para requisição de criação de parecer
*/
export interface CreateOpinionRequest { export interface CreateOpinionRequest {
titulo: string; titulo: string;
categoria: string; categoria: string;
@@ -22,9 +16,8 @@ export interface CreateOpinionRequest {
estabelecimentoId?: number; estabelecimentoId?: number;
} }
/** export type OpinionStatus = 'processando' | 'concluido' | 'erro';
* Interface para um parecer retornado pela API
*/
export interface OpinionRecord { export interface OpinionRecord {
id: string; id: string;
estabelecimento_id: number; estabelecimento_id: number;
@@ -35,11 +28,10 @@ export interface OpinionRecord {
file_url: string; file_url: string;
created_at: string; created_at: string;
file_url_melhoria: string; file_url_melhoria: string;
status?: OpinionStatus;
isLocalPending?: boolean;
} }
/**
* Interface para parâmetros de paginação e busca
*/
export interface GetOpinionsParams { export interface GetOpinionsParams {
page?: number; page?: number;
per_page?: number; per_page?: number;
@@ -48,9 +40,6 @@ export interface GetOpinionsParams {
estabelecimentoId?: number; estabelecimentoId?: number;
} }
/**
* Interface para resposta do GET de pareceres
*/
export interface GetOpinionsResponse { export interface GetOpinionsResponse {
data: OpinionRecord[]; data: OpinionRecord[];
total: number; total: number;
@@ -58,317 +47,160 @@ export interface GetOpinionsResponse {
per_page: number; per_page: number;
} }
/** type ApiErrorShape = { message?: string; status?: number };
* Serviço para gerenciamento de pareceres jurídicos
*/
class AgentService { class AgentService {
private readonly CREATE_OPINION_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico'; private readonly CREATE_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
private readonly GET_OPINIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_parecer'; private readonly OPINIONS_BASE = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex';
/** private resolveUserContext(userEmail?: string, estabelecimentoId?: number) {
* Cria um novo parecer técnico
*
* @param request - Dados do parecer
* @returns Promise com a resposta da API
*/
async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
const {
titulo,
categoria,
instrucoes,
userEmail,
estabelecimentoId
} = request;
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail); const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo); const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
return { email, estabId };
// Validações
if (!titulo || titulo.trim().length === 0) {
throw {
success: false,
message: 'Título do parecer é obrigatório',
};
}
if (!instrucoes || instrucoes.trim().length === 0) {
throw {
success: false,
message: 'Instruções são obrigatórias',
};
} }
private assertUserContext(email: unknown, estabId: unknown) {
if (!email) { if (!email) {
throw { throw { success: false, message: 'Email do usuário não fornecido' };
success: false,
message: 'Email do usuário não fornecido',
};
} }
if (!estabId) { if (!estabId) {
throw { success: false, message: 'ID do estabelecimento não fornecido' };
}
}
private toApiError(error: unknown): never {
const e = error as ApiErrorShape;
throw { throw {
success: false, success: false,
message: 'ID do estabelecimento não fornecido', message: e?.message || 'Erro desconhecido',
status: e?.status,
}; };
} }
// Log para debug async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
console.log('Criando parecer:', { const { titulo, categoria, instrucoes, userEmail, estabelecimentoId } = request;
titulo,
categoria, if (!titulo?.trim()) {
instrucoesLength: instrucoes.length, throw { success: false, message: 'Título do parecer é obrigatório' };
userEmail: email, }
estabelecimentoId: estabId,
}); if (!instrucoes?.trim()) {
throw { success: false, message: 'Instruções são obrigatórias' };
}
const { email, estabId } = this.resolveUserContext(userEmail, estabelecimentoId);
this.assertUserContext(email, estabId);
try { try {
// Faz a requisição usando o serviço de API
const response = await apiService.post<CreateOpinionResponse>( const response = await apiService.post<CreateOpinionResponse>(
this.CREATE_OPINION_ENDPOINT, this.CREATE_ENDPOINT,
{ {
user_email: email, user_email: email,
estabelecimento_id: estabId, estabelecimento_id: estabId,
titulo: titulo.trim(), titulo: titulo.trim(),
categoria: categoria.trim(), categoria: categoria.trim(),
instrucoes: instrucoes.trim(), instrucoes: instrucoes.trim(),
} },
{ timeout: 600_000 }
); );
console.log('Resposta da API (criar parecer):', response.data);
return response.data; return response.data;
} catch (error: any) { } catch (error: unknown) {
// Trata erros específicos
console.error('Erro ao criar parecer:', error); console.error('Erro ao criar parecer:', error);
this.toApiError(error);
throw {
success: false,
message: error.message || 'Erro ao criar parecer',
status: error.status,
};
} }
} }
/**
* Busca todos os pareceres do usuário com paginação e busca
*
* @param params - Parâmetros de paginação e busca
* @returns Promise com array de pareceres
*/
async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> { async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> {
const { const { page = 1, per_page = 10, search = '', userEmail, estabelecimentoId } = params || {};
page = 1,
per_page = 10,
search = '',
userEmail,
estabelecimentoId
} = params || {};
// Usa valores do GlobalFunctions se não forem fornecidos const { email, estabId } = this.resolveUserContext(userEmail, estabelecimentoId);
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail); this.assertUserContext(email, estabId);
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!estabId) {
throw {
success: false,
message: 'ID do estabelecimento não fornecido',
};
}
console.log('Buscando pareceres:', {
userEmail: email,
estabelecimentoId: estabId,
page,
per_page,
search,
});
try { try {
// Constrói a URL com parâmetros de query const url = `${this.OPINIONS_BASE}/get_parecer/${email}/${estabId}`;
const url = `${this.GET_OPINIONS_ENDPOINT}/${email}/${estabId}`;
const response = await apiService.get<OpinionRecord[]>(url, { const response = await apiService.get<OpinionRecord[] | GetOpinionsResponse>(url, {
params: { params: { page, per_page, search },
page,
per_page,
search,
},
}); });
console.log('Resposta da API (buscar pareceres):', response.data); const raw = response.data;
// A API retorna diretamente o array de pareceres if (Array.isArray(raw)) return raw;
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto, tenta encontrar o array dentro dele
console.warn('API retornou objeto em vez de array:', response.data);
if (Array.isArray((response.data as any).data)) { if (raw && typeof raw === 'object') {
return (response.data as any).data; if (Array.isArray((raw as GetOpinionsResponse).data)) return (raw as GetOpinionsResponse).data;
} else if (Array.isArray((response.data as any).opinions)) { if (Array.isArray((raw as { opinions?: OpinionRecord[] }).opinions)) {
return (response.data as any).opinions; return (raw as unknown as { opinions: OpinionRecord[] }).opinions;
} }
} }
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de pareceres da resposta');
return []; return [];
} catch (error: any) { } catch (error: unknown) {
console.error('Erro ao buscar pareceres:', error); console.error('Erro ao buscar pareceres:', error);
this.toApiError(error);
throw {
success: false,
message: error.message || 'Erro ao buscar pareceres',
status: error.status,
};
} }
} }
/**
* Faz download de um arquivo de parecer
*
* @param fileUrl - URL do arquivo a ser baixado
* @param fileName - Nome do arquivo para download
*/
async downloadOpinion(fileUrl: string, fileName: string): Promise<void> { async downloadOpinion(fileUrl: string, fileName: string): Promise<void> {
if (!fileUrl) { if (!fileUrl) {
throw { throw { success: false, message: 'URL do arquivo não fornecida' };
success: false,
message: 'URL do arquivo não fornecida',
};
} }
try { const triggerDownload = (href: string) => {
console.log('Iniciando download:', { fileUrl, fileName }); const link = document.createElement('a');
link.href = href;
link.download = fileName;
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Tenta primeiro fazer o download via fetch (funciona se CORS estiver configurado)
try { try {
const response = await fetch(fileUrl, { const response = await fetch(fileUrl, { method: 'GET', mode: 'cors', cache: 'no-cache' });
method: 'GET',
mode: 'cors',
cache: 'no-cache',
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Erro HTTP: ${response.status}`); throw new Error(`Erro HTTP: ${response.status}`);
} }
// Converte a resposta em blob
const blob = await response.blob(); const blob = await response.blob();
// Cria uma URL temporária para o blob
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
triggerDownload(blobUrl);
// Cria um elemento <a> temporário para forçar o download
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
// Remove o elemento e libera a URL temporária
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
} catch {
console.log('Download via fetch concluído:', { fileUrl, fileName });
return;
} catch (fetchError: any) {
console.warn('Erro no download via fetch, tentando método alternativo:', fetchError.message);
// Se falhar (erro de CORS), usa o método alternativo de abrir em nova aba
// Isso permite que o navegador force o download mesmo com restrições de CORS
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Para S3, podemos tentar adicionar parâmetros que forçam o download
const url = new URL(fileUrl); const url = new URL(fileUrl);
url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`); url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
link.href = url.toString(); triggerDownload(url.toString());
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('Download via link direto iniciado:', { fileUrl, fileName });
}
} catch (error: any) {
console.error('Erro ao fazer download:', error);
throw {
success: false,
message: error.message || 'Erro ao fazer download do arquivo. Verifique se a URL está acessível.',
};
} }
} }
/**
* Exclui um parecer técnico
*
* @param opinionId - ID do parecer a ser excluído
* @param userEmail - Email do usuário (opcional, usa GlobalFunctions se não fornecido)
* @returns Promise com a resposta da API
*/
async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> { async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> {
// Usa valores do GlobalFunctions se não forem fornecidos if (!opinionId) {
throw { success: false, message: 'ID do parecer não fornecido' };
}
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail); const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!opinionId) {
throw {
success: false,
message: 'ID do parecer não fornecido',
};
}
if (!email) { if (!email) {
throw { throw { success: false, message: 'Email do usuário não fornecido' };
success: false,
message: 'Email do usuário não fornecido',
};
} }
console.log('Excluindo parecer:', {
opinionId,
userEmail: email,
});
try { try {
// Constrói a URL com o user_email e id const url = `${this.OPINIONS_BASE}/delete_parecer/${email}/${opinionId}`;
const url = `/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_parecer/${email}/${opinionId}`;
const response = await apiService.delete<{ success: boolean }[]>(url); const response = await apiService.delete<{ success: boolean }[]>(url);
console.log('Resposta da API (excluir parecer):', response.data);
// A API retorna um array com { success: true }
if (Array.isArray(response.data) && response.data.length > 0) { if (Array.isArray(response.data) && response.data.length > 0) {
return response.data[0]; return response.data[0];
} }
return { success: true }; return { success: true };
} catch (error: any) { } catch (error: unknown) {
console.error('Erro ao excluir parecer:', error); console.error('Erro ao excluir parecer:', error);
this.toApiError(error);
throw {
success: false,
message: error.message || 'Erro ao excluir parecer',
status: error.status,
};
} }
} }
} }
// Exporta instância única (Singleton)
export const agentService = new AgentService(); export const agentService = new AgentService();
+14 -72
View File
@@ -1,63 +1,34 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
/**
* Configuração centralizada da API
* Todas as chamadas de API devem usar este serviço para garantir
* autenticação e configuração consistente
*/
class ApiService { class ApiService {
private axiosInstance: AxiosInstance; private readonly axiosInstance: AxiosInstance;
private apiKey: string; private readonly apiKey: string;
private baseURL: string;
constructor() { constructor() {
// Busca configurações das variáveis de ambiente
this.apiKey = import.meta.env.VITE_API_KEY || ''; this.apiKey = import.meta.env.VITE_API_KEY || '';
this.baseURL = import.meta.env.VITE_API_BASE_URL || ''; const baseURL = import.meta.env.VITE_API_BASE_URL || '';
// Validação das variáveis de ambiente
if (!this.apiKey) {
console.error('VITE_API_KEY não configurada no arquivo .env');
}
if (!this.baseURL) {
console.error('VITE_API_BASE_URL não configurada no arquivo .env');
}
// Cria instância do Axios com configurações padrão
this.axiosInstance = axios.create({ this.axiosInstance = axios.create({
baseURL: this.baseURL, baseURL,
timeout: 60000, // 60 segundos para upload de arquivos timeout: 60_000,
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
}); });
// Interceptor para adicionar API Key em todas as requisições this.axiosInstance.interceptors.request.use((config) => {
this.axiosInstance.interceptors.request.use(
(config) => {
// Adiciona a API Key no header de todas as requisições
if (this.apiKey) { if (this.apiKey) {
config.headers['apikey'] = this.apiKey; config.headers['apikey'] = this.apiKey;
} }
return config; return config;
}, });
(error) => {
return Promise.reject(error);
}
);
// Interceptor de resposta para tratamento centralizado de erros
this.axiosInstance.interceptors.response.use( this.axiosInstance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
// Log de erro para debug
console.error('API Error:', { console.error('API Error:', {
message: error.message, message: error.message,
status: error.response?.status, status: error.response?.status,
data: error.response?.data, data: error.response?.data,
}); });
// Retorna erro formatado
return Promise.reject({ return Promise.reject({
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor', message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
status: error.response?.status, status: error.response?.status,
@@ -67,61 +38,32 @@ class ApiService {
); );
} }
/** get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
* Requisição GET
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(url, config); return this.axiosInstance.get<T>(url, config);
} }
/** post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
* Requisição POST
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(url, data, config); return this.axiosInstance.post<T>(url, data, config);
} }
/** postFormData<T = unknown>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
* Requisição POST com FormData (para upload de arquivos)
*/
async postFormData<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(url, formData, { return this.axiosInstance.post<T>(url, formData, {
...config, ...config,
headers: { headers: { ...config?.headers, 'Content-Type': 'multipart/form-data' },
...config?.headers,
'Content-Type': 'multipart/form-data',
},
}); });
} }
/** put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
* Requisição PUT
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.put<T>(url, data, config); return this.axiosInstance.put<T>(url, data, config);
} }
/** delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
* Requisição DELETE
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(url, config); return this.axiosInstance.delete<T>(url, config);
} }
/**
* Retorna a URL base configurada
*/
getBaseURL(): string {
return this.baseURL;
}
/**
* Retorna a instância do Axios (uso avançado)
*/
getInstance(): AxiosInstance { getInstance(): AxiosInstance {
return this.axiosInstance; return this.axiosInstance;
} }
} }
// Exporta instância única (Singleton)
export const apiService = new ApiService(); export const apiService = new ApiService();
+196
View File
@@ -0,0 +1,196 @@
import { apiService } from "./api";
export interface AreaItem {
id: string;
nome: string;
descricao: string;
created_at: string;
updated_at: string;
}
export interface CriarAreaRequest {
nome: string;
descricao: string;
}
export interface CriarAreaSuccessResponse {
success: true;
id: string;
nome: string;
descricao: string;
}
export interface CriarAreaErrorResponse {
success: false;
message: string;
}
export type CriarAreaResponse = CriarAreaSuccessResponse | CriarAreaErrorResponse;
export interface EditarAreaBody {
nome: string;
descricao: string;
}
export interface EditarAreaSuccessResponse {
success: true;
id: string;
nome: string;
descricao: string;
}
export interface EditarAreaErrorResponse {
success: false;
message: string;
}
export interface DeletarAreaSuccessResponse {
success: true;
message: string;
}
export interface DeletarAreaErrorResponse {
success: false;
message: string;
}
export interface ListarAreasParams {
nome?: string;
page: number;
per_page: number;
}
export interface ListarAreasResponseBody {
success: true;
total_registros: number;
total_paginas: number;
per_page: number;
pagina_atual: number;
data: AreaItem[];
}
class AreasService {
async criar(nome: string, descricao: string): Promise<CriarAreaSuccessResponse> {
const body: CriarAreaRequest = {
nome: nome.trim(),
descricao: (descricao ?? "").trim(),
};
const response = await apiService.post<CriarAreaResponse>(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/criar-area",
body
);
if (response.data.success === false) {
throw new Error(response.data.message ?? "Erro ao criar área");
}
return response.data as CriarAreaSuccessResponse;
}
async listar(params: ListarAreasParams): Promise<ListarAreasResponseBody> {
const { page, per_page, nome } = params;
const response = await apiService.get<ListarAreasResponseBody[]>(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/listar-areas",
{
params: {
...(nome != null && nome.trim() !== "" ? { nome: nome.trim() } : {}),
page,
per_page,
},
}
);
const first = Array.isArray(response.data) ? response.data[0] : response.data;
if (!first || first.success !== true) {
throw new Error("Resposta inválida ao listar áreas");
}
return first;
}
async listarTotal(): Promise<AreaItem[]> {
interface ListarAreasTotalResponseItem {
success: true;
data: AreaItem[];
}
const response = await apiService.get<ListarAreasTotalResponseItem[]>(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/listar-areas-total"
);
const first = Array.isArray(response.data) ? response.data[0] : response.data;
if (!first || first.success !== true || !Array.isArray(first.data)) {
throw new Error("Resposta inválida ao listar áreas");
}
return first.data;
}
async editar(id: string, nome: string, descricao: string): Promise<EditarAreaSuccessResponse> {
if (!id?.trim()) {
throw new Error("ID da área é obrigatório");
}
const idEnc = encodeURIComponent(id.trim());
const body: EditarAreaBody = {
nome: nome.trim(),
descricao: (descricao ?? "").trim(),
};
/** Mesmo prefixo de webhook que o fluxo n8n (UUID) usado nos endpoints de "parecer/areas". */
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/parecer/area/${idEnc}`;
const response = await apiService.put<
EditarAreaSuccessResponse | EditarAreaErrorResponse | (EditarAreaSuccessResponse | EditarAreaErrorResponse)[]
>(
url,
body
);
const raw = response.data;
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data || typeof data !== "object" || !("success" in data)) {
throw new Error("Resposta inválida ao editar área");
}
if (data.success === false) {
throw new Error((data as EditarAreaErrorResponse).message ?? "Erro ao editar área");
}
return data as EditarAreaSuccessResponse;
}
async deletar(id: string): Promise<DeletarAreaSuccessResponse> {
if (!id?.trim()) {
throw new Error("ID da área é obrigatório");
}
const idEnc = encodeURIComponent(id.trim());
/** Mesmo prefixo de webhook que `parecerApi.excluir` (fluxo n8n com UUID). */
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/4e6c2374-9c22-4c81-b558-45f0cfefa5c3/codex/parecer/areas/deletar/${idEnc}`;
const response = await apiService.delete<
DeletarAreaSuccessResponse | DeletarAreaErrorResponse | (DeletarAreaSuccessResponse | DeletarAreaErrorResponse)[]
>(url);
const raw = response.data;
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data || typeof data !== "object" || !("success" in data)) {
throw new Error("Resposta inválida ao excluir área");
}
if (data.success === false) {
throw new Error((data as DeletarAreaErrorResponse).message ?? "Erro ao excluir área");
}
return data as DeletarAreaSuccessResponse;
}
}
export const areasService = new AreasService();
+187
View File
@@ -0,0 +1,187 @@
import axios from 'axios';
import { GlobalFunctions } from '@/GlobalFunctions';
export interface AsanaWorkspaceResponse {
gid: string;
resource_type: string;
name: string;
}
export interface AsanaWorkspacesResponse {
data: AsanaWorkspaceResponse[];
}
export interface AsanaWorkspace {
id: string;
name: string;
}
export interface AsanaUserResponse {
gid: string;
name: string;
resource_type: string;
}
export interface AsanaUsersResponse {
data: AsanaUserResponse[];
}
export interface AsanaUser {
id: string;
name: string;
}
export interface AsanaIntegrationResponse {
success: boolean;
integracao_id?: string;
id?: string;
api_key?: string;
workspace_gid?: string;
workspace_nome?: string;
usuario_asana_gid?: string;
usuario_asana_nome?: string;
}
export interface AsanaIntegrationRequest {
user_id?: string;
api_key: string;
workspace_gid: string;
workspace_nome: string;
usuario_asana_gid: string;
usuario_asana_nome: string;
}
type AxiosLikeError = { response?: { data?: { message?: string }; status?: number } };
class AsanaService {
private readonly ASANA_BASE_URL = 'https://app.asana.com/api/1.0';
private asanaHeaders(token: string): Record<string, string> {
return {
accept: 'application/json',
authorization: `Bearer ${token.trim()}`,
};
}
private async n8nHeaders(): Promise<Record<string, string>> {
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
accept: 'application/json',
};
if (apiKey) headers['apikey'] = apiKey;
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
private handleAsanaError(error: unknown, fallback: string): never {
const e = error as AxiosLikeError;
if (e?.response?.status === 401) {
throw { success: false, message: 'Token inválido ou expirado. Verifique sua chave de API.' };
}
if (e?.response?.data) {
throw { success: false, message: e.response.data.message || fallback };
}
throw { success: false, message: error instanceof Error ? error.message : fallback };
}
async getWorkspaces(token: string): Promise<AsanaWorkspace[]> {
if (!token?.trim()) {
throw { success: false, message: 'Token do Asana é obrigatório' };
}
try {
const response = await axios.get<AsanaWorkspacesResponse>(
`${this.ASANA_BASE_URL}/workspaces`,
{ headers: this.asanaHeaders(token) }
);
return response.data.data.map((w) => ({ id: w.gid, name: w.name }));
} catch (error: unknown) {
console.error('Erro ao buscar workspaces do Asana:', error);
this.handleAsanaError(error, 'Erro ao buscar workspaces do Asana');
}
}
async getUsers(token: string, workspaceId: string): Promise<AsanaUser[]> {
if (!token?.trim()) {
throw { success: false, message: 'Token do Asana é obrigatório' };
}
if (!workspaceId?.trim()) {
throw { success: false, message: 'ID do workspace é obrigatório' };
}
try {
const response = await axios.get<AsanaUsersResponse>(
`${this.ASANA_BASE_URL}/users?workspace=${workspaceId}`,
{ headers: this.asanaHeaders(token) }
);
return response.data.data.map((u) => ({ id: u.gid, name: u.name }));
} catch (error: unknown) {
console.error('Erro ao buscar usuários do Asana:', error);
this.handleAsanaError(error, 'Erro ao buscar usuários do Asana');
}
}
async getIntegration(userId: string): Promise<AsanaIntegrationResponse | null> {
if (!userId?.trim()) {
throw { success: false, message: 'ID do usuário é obrigatório' };
}
try {
const headers = await this.n8nHeaders();
const response = await axios.get<AsanaIntegrationResponse>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/c898beff-84cb-44df-a69c-6eff27ccd7aa/codex/agente-pessoal/integracoes/asana/${userId}`,
{ headers }
);
return response.data.success ? response.data : null;
} catch (error: unknown) {
const e = error as AxiosLikeError;
if (e?.response?.status === 404) return null;
return null;
}
}
async createIntegration(request: AsanaIntegrationRequest): Promise<AsanaIntegrationResponse> {
if (!request.user_id || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
throw { success: false, message: 'Dados incompletos para criar integração' };
}
try {
const headers = await this.n8nHeaders();
const response = await axios.post<AsanaIntegrationResponse>(
'https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/integracoes/asana',
request,
{ headers }
);
return response.data;
} catch (error: unknown) {
console.error('Erro ao criar integração do Asana:', error);
this.handleAsanaError(error, 'Erro ao criar integração do Asana');
}
}
async updateIntegration(
integracaoId: string,
request: Omit<AsanaIntegrationRequest, 'user_id'>
): Promise<AsanaIntegrationResponse> {
if (!integracaoId || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
throw { success: false, message: 'Dados incompletos para atualizar integração' };
}
try {
const headers = await this.n8nHeaders();
const response = await axios.post<AsanaIntegrationResponse>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/c898beff-84cb-44df-a69c-6eff27ccd7aa/codex/agente-pessoal/integracoes/asana/${integracaoId}`,
request,
{ headers }
);
return response.data;
} catch (error: unknown) {
console.error('Erro ao atualizar integração do Asana:', error);
this.handleAsanaError(error, 'Erro ao atualizar integração do Asana');
}
}
}
export const asanaService = new AsanaService();
+54
View File
@@ -0,0 +1,54 @@
import { apiService } from "./api";
const ASSISTANT_TIMEOUT_MS = 300_000;
export interface AssistantOutputResponse {
output?: string;
}
class AssistentePromptsService {
async gerar(instrucao: string, signal?: AbortSignal): Promise<string> {
if (!instrucao?.trim()) {
throw new Error("Instrução é obrigatória");
}
const response = await apiService.post<AssistantOutputResponse>(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/assistente-criar-prompt",
{ instrucao: instrucao.trim() },
{ timeout: ASSISTANT_TIMEOUT_MS, signal }
);
if (response.data?.output != null) {
return String(response.data.output);
}
throw new Error("Resposta da API sem texto gerado (output)");
}
async refinar(promptAtual: string, instrucao: string, signal?: AbortSignal): Promise<string> {
if (!promptAtual?.trim()) {
throw new Error("Prompt atual é obrigatório");
}
if (!instrucao?.trim()) {
throw new Error("Instrução é obrigatória");
}
const response = await apiService.post<AssistantOutputResponse>(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/assistente-melhorar-prompt",
{
instrucao: instrucao.trim(),
prompt_atual: promptAtual.trim(),
},
{ timeout: ASSISTANT_TIMEOUT_MS, signal }
);
if (response.data?.output != null) {
return String(response.data.output);
}
throw new Error("Resposta da API sem texto gerado (output)");
}
}
export const assistentePromptsService = new AssistentePromptsService();
+167 -259
View File
@@ -1,24 +1,15 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api'; import { apiService } from './api';
/**
* Tipos de vozes disponíveis para geração de áudio
*/
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer'; export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
/**
* Interface para a resposta da API de geração de áudio
*/
export interface AudioGenerationResponse { export interface AudioGenerationResponse {
success: boolean; success: boolean;
audio_url: string; // URL do áudio gerado audio_url: string;
audio_generation_id: string; audio_generation_id: string;
message: string; // Texto que foi convertido em áudio message: string;
} }
/**
* Interface para um áudio armazenado no banco de dados
*/
export interface AudioRecord { export interface AudioRecord {
id: string; id: string;
user_email: string; user_email: string;
@@ -33,9 +24,6 @@ export interface AudioRecord {
created_at: string; created_at: string;
} }
/**
* Interface para os dados necessários para geração de áudio
*/
export interface AudioGenerationRequest { export interface AudioGenerationRequest {
message: string; message: string;
voice: VoiceType; voice: VoiceType;
@@ -43,143 +31,80 @@ export interface AudioGenerationRequest {
estabelecimentoId?: number; estabelecimentoId?: number;
} }
/**
* Informações sobre cada tipo de voz disponível
*/
export const VOICE_OPTIONS = { export const VOICE_OPTIONS = {
alloy: { alloy: {
label: "Alloy", label: 'Alloy',
gender: "Masculina", gender: 'Masculina',
style: "Neutra, equilibrada, tom corporativo", style: 'Neutra, equilibrada, tom corporativo',
description: "Boa para tutoriais e comunicações institucionais." description: 'Boa para tutoriais e comunicações institucionais.',
}, },
echo: { echo: {
label: "Echo", label: 'Echo',
gender: "Masculina", gender: 'Masculina',
style: "Forte e profissional, mais grave", style: 'Forte e profissional, mais grave',
description: "Ideal para voz de autoridade ou locução firme." description: 'Ideal para voz de autoridade ou locução firme.',
}, },
fable: { fable: {
label: "Fable", label: 'Fable',
gender: "Feminina", gender: 'Feminina',
style: "Narrativa, calorosa e envolvente", style: 'Narrativa, calorosa e envolvente',
description: "Ótima para storytelling e áudios empáticos." description: 'Ótima para storytelling e áudios empáticos.',
}, },
onyx: { onyx: {
label: "Onyx", label: 'Onyx',
gender: "Masculina", gender: 'Masculina',
style: "Grave, autoritária, impactante", style: 'Grave, autoritária, impactante',
description: "Excelente para trailers, mensagens sérias ou institucionais." description: 'Excelente para trailers, mensagens sérias ou institucionais.',
}, },
nova: { nova: {
label: "Nova", label: 'Nova',
gender: "Feminina", gender: 'Feminina',
style: "Brilhante, animada, energética", style: 'Brilhante, animada, energética',
description: "Boa para vídeos curtos, marketing ou conteúdos leves." description: 'Boa para vídeos curtos, marketing ou conteúdos leves.',
}, },
shimmer: { shimmer: {
label: "Shimmer", label: 'Shimmer',
gender: "Feminina", gender: 'Feminina',
style: "Suave, otimista, clara", style: 'Suave, otimista, clara',
description: "Boa para mensagens acolhedoras, explicações e IA conversacional." description: 'Boa para mensagens acolhedoras, explicações e IA conversacional.',
} },
} as const; } as const;
/** type ApiErrorShape = { message?: string; status?: number };
* Serviço de geração de áudio (Text-to-Speech)
*/
class AudioGenerationService { class AudioGenerationService {
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio'; private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
private readonly GET_AUDIOS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_gerar_audios'; private readonly GET_AUDIOS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_gerar_audios';
private readonly DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio'; private readonly DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio';
/** private resolveEmail(userEmail?: string): string {
* Gera um arquivo de áudio a partir de texto return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
*
* @param request - Dados da requisição (texto, voz, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
const { message, voice, userEmail, estabelecimentoId } = request;
// Usa valores do .env se não forem fornecidos
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
// Valida o texto
if (!message || message.trim().length === 0) {
throw {
success: false,
message: 'O texto não pode estar vazio',
};
} }
// Valida a voz private toApiError(error: unknown, fallback: string): never {
if (!this.isValidVoice(voice)) { const e = error as ApiErrorShape;
throw { throw { success: false, message: e?.message || fallback, status: e?.status };
success: false,
message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}`,
};
} }
// Log para debug (remover em produção se necessário) private extractArray<T>(data: unknown, keys: string[]): T[] {
console.log('Gerando áudio:', { if (Array.isArray(data)) return data as T[];
messageLength: message.length, if (data && typeof data === 'object') {
voice, for (const key of keys) {
userEmail: email, const candidate = (data as Record<string, unknown>)[key];
estabelecimentoId: estabId, if (Array.isArray(candidate)) return candidate as T[];
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.post<AudioGenerationResponse>(
this.AUDIO_GENERATION_ENDPOINT,
{
estabelecimento_id: estabId,
user_email: email,
message: message,
voice: voice,
}
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro na geração de áudio:', error);
throw {
success: false,
message: error.message || 'Erro ao gerar áudio',
status: error.status,
};
} }
} }
return [];
}
/**
* Valida se a voz selecionada é suportada
*
* @param voice - Voz a ser validada
* @returns true se a voz é válida
*/
isValidVoice(voice: string): voice is VoiceType { isValidVoice(voice: string): voice is VoiceType {
return Object.keys(VOICE_OPTIONS).includes(voice); return Object.keys(VOICE_OPTIONS).includes(voice);
} }
/**
* Obtém informações sobre uma voz específica
*
* @param voice - Tipo de voz
* @returns Informações da voz
*/
getVoiceInfo(voice: VoiceType) { getVoiceInfo(voice: VoiceType) {
return VOICE_OPTIONS[voice]; return VOICE_OPTIONS[voice];
} }
/**
* Lista todas as vozes disponíveis
*
* @returns Array com todas as opções de voz
*/
getAllVoices() { getAllVoices() {
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({ return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
value: key as VoiceType, value: key as VoiceType,
@@ -187,174 +112,157 @@ class AudioGenerationService {
})); }));
} }
/** validateText(text: string, maxLength = 4096): { valid: boolean; error?: string } {
* Valida o texto para geração de áudio if (!text?.trim()) return { valid: false, error: 'O texto não pode estar vazio' };
* if (text.length > maxLength) return { valid: false, error: `O texto é muito longo. Máximo: ${maxLength} caracteres` };
* @param text - Texto a ser validado
* @param maxLength - Comprimento máximo (padrão: 4096 caracteres)
* @returns Objeto com resultado da validação
*/
validateText(text: string, maxLength: number = 4096): { valid: boolean; error?: string } {
if (!text || text.trim().length === 0) {
return {
valid: false,
error: 'O texto não pode estar vazio',
};
}
if (text.length > maxLength) {
return {
valid: false,
error: `O texto é muito longo. Máximo: ${maxLength} caracteres`,
};
}
return { valid: true }; return { valid: true };
} }
/** async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
* Lista áudios do banco de dados com paginação const { message, voice } = request;
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com o array de áudios
*/
async getAudios(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<AudioRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) { if (!message?.trim()) {
throw { throw { success: false, message: 'O texto não pode estar vazio' };
success: false,
message: 'Email do usuário não fornecido',
};
} }
console.log('Buscando áudios:', { if (!this.isValidVoice(voice)) {
userEmail: email, throw { success: false, message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}` };
page, }
perPage,
}); const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
try { try {
// Faz a requisição GET com parâmetros na URL e query const response = await apiService.post<AudioGenerationResponse>(
const response = await apiService.get<AudioRecord[]>( this.AUDIO_GENERATION_ENDPOINT,
`${this.GET_AUDIOS_ENDPOINT}/${email}`, { estabelecimento_id: estabId, user_email: email, message, voice }
{
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
); );
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de áudios
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data; return response.data;
} else if (response.data && typeof response.data === 'object') { } catch (error: unknown) {
// Se a resposta for um objeto com uma propriedade 'audios' ou similar console.error('Erro na geração de áudio:', error);
console.warn('API retornou objeto em vez de array:', response.data); this.toApiError(error, 'Erro ao gerar áudio');
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).audios)) {
return (response.data as any).audios;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
} }
} }
// Se não conseguir extrair array, retorna vazio async getAudios(userEmail?: string, page = 1, perPage = 10): Promise<AudioRecord[]> {
console.warn('Não foi possível extrair array de áudios da resposta'); const email = this.resolveEmail(userEmail);
return [];
} catch (error: any) {
console.error('Erro ao buscar áudios:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar áudios',
status: error.status,
};
}
}
/**
* Deleta um áudio do banco de dados
*
* @param audioId - ID do áudio a ser deletado
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) { if (!email) {
throw { throw { success: false, message: 'Email do usuário não fornecido' };
success: false,
message: 'Email do usuário não fornecido',
};
} }
if (!audioId) {
throw {
success: false,
message: 'ID do áudio não fornecido',
};
}
console.log('Deletando áudio:', {
audioId,
userEmail: email,
});
try { try {
// Faz a requisição DELETE com parâmetros na URL const response = await apiService.get<unknown>(
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
{ params: { page: page.toString(), per_page: perPage.toString() } }
);
return this.extractArray<AudioRecord>(response.data, ['audios', 'data']);
} catch (error: unknown) {
console.error('Erro ao buscar áudios:', error);
this.toApiError(error, 'Erro ao buscar áudios');
}
}
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
const email = this.resolveEmail(userEmail);
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
if (!audioId) throw { success: false, message: 'ID do áudio não fornecido' };
try {
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>( const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}` `${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}`
); );
const result = Array.isArray(response.data) ? response.data[0] : response.data;
console.log('Resposta completa do DELETE:', response); return { success: result.success ?? true, message: result.message || 'Áudio deletado com sucesso' };
console.log('response.data:', response.data); } catch (error: unknown) {
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Áudio deletado com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar áudio:', error); console.error('Erro ao deletar áudio:', error);
this.toApiError(error, 'Erro ao deletar áudio');
}
}
throw { /**
success: false, * Baixa o arquivo no navegador (sem navegar para a URL).
message: error.message || 'Erro ao deletar áudio', * Usa blob local + <a download> — necessário porque links cross-origin ignoram `download` e abrem a URL.
status: error.status, */
async downloadAudioFile(audioUrl: string, filename?: string): Promise<void> {
const urlTrim = audioUrl?.trim();
if (!urlTrim) {
throw new Error('URL do áudio inválida');
}
const safeName = (filename || `audio_${Date.now()}.mp3`).replace(/[/\\?%*:|"<>]/g, '_');
let sameOrigin = false;
try {
const u = new URL(urlTrim, typeof window !== 'undefined' ? window.location.href : undefined);
sameOrigin = typeof window !== 'undefined' && u.origin === window.location.origin;
} catch {
sameOrigin = false;
}
const fetchBlob = async (): Promise<Blob> => {
const response = await fetch(urlTrim, {
mode: 'cors',
credentials: sameOrigin ? 'include' : 'omit',
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`Falha ao baixar (HTTP ${response.status})`);
}
return response.blob();
}; };
const xhrBlob = (): Promise<Blob> =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', urlTrim, true);
xhr.responseType = 'blob';
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject(new Error(`Falha ao baixar (HTTP ${xhr.status})`));
}
};
xhr.onerror = () => reject(new Error('Falha de rede ao baixar o áudio'));
xhr.send();
});
let blob: Blob;
try {
blob = await fetchBlob();
} catch (e1) {
try {
blob = await xhrBlob();
} catch (e2) {
console.error('downloadAudioFile:', e1, e2);
throw new Error(
'Não foi possível baixar o áudio. Se o arquivo estiver em outro domínio, é preciso CORS liberando GET para esta origem.'
);
}
}
const needsMimeFix =
!blob.type ||
blob.type === 'application/octet-stream' ||
blob.type === 'text/html';
const typedBlob = needsMimeFix ? new Blob([blob], { type: 'audio/mpeg' }) : blob;
const objectUrl = URL.createObjectURL(typedBlob);
try {
const a = document.createElement('a');
a.href = objectUrl;
a.download = safeName.endsWith('.mp3') ? safeName : `${safeName}.mp3`;
a.style.display = 'none';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
URL.revokeObjectURL(objectUrl);
} }
} }
} }
// Exporta instância única (Singleton)
export const audioGenerationService = new AudioGenerationService(); export const audioGenerationService = new AudioGenerationService();
+235 -790
View File
File diff suppressed because it is too large Load Diff
+71 -290
View File
@@ -1,25 +1,16 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api'; import { apiService } from './api';
/**
* Tamanhos de imagem disponíveis
*/
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024'; export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
/**
* Interface para a resposta da API de geração de imagens
*/
export interface ImageGenerationResponse { export interface ImageGenerationResponse {
success: boolean; success: boolean;
image_url?: string; // URL da imagem gerada (opcional quando há erro) image_url?: string;
image_generation_id?: string; // ID da geração (opcional quando há erro) image_generation_id?: string;
message: string; // Descrição original ou mensagem de erro message: string;
code?: string; // Código de erro (ex: "server_error", "invalid_request") code?: string;
} }
/**
* Interface para uma imagem armazenada no banco de dados
*/
export interface ImageRecord { export interface ImageRecord {
id: string; id: string;
user_email: string; user_email: string;
@@ -35,9 +26,6 @@ export interface ImageRecord {
created_at: string; created_at: string;
} }
/**
* Interface para resposta paginada de imagens
*/
export interface GetImagesResponse { export interface GetImagesResponse {
images: ImageRecord[]; images: ImageRecord[];
total: number; total: number;
@@ -46,9 +34,6 @@ export interface GetImagesResponse {
total_pages: number; total_pages: number;
} }
/**
* Interface para os dados necessários para geração de imagem
*/
export interface ImageGenerationRequest { export interface ImageGenerationRequest {
description: string; description: string;
size: ImageSize; size: ImageSize;
@@ -56,9 +41,6 @@ export interface ImageGenerationRequest {
estabelecimentoId?: number; estabelecimentoId?: number;
} }
/**
* Informações sobre cada tamanho de imagem disponível
*/
export const IMAGE_SIZE_OPTIONS = { export const IMAGE_SIZE_OPTIONS = {
'1024x1024': { '1024x1024': {
label: 'Quadrado', label: 'Quadrado',
@@ -80,127 +62,41 @@ export const IMAGE_SIZE_OPTIONS = {
}, },
} as const; } as const;
/** type ApiErrorShape = { message?: string; status?: number; success?: boolean; code?: string };
* Serviço de geração de imagens com IA
*/
class ImageGenerationService { class ImageGenerationService {
private readonly IMAGE_GENERATION_ENDPOINT = '/webhook/codex/image_generator'; private readonly IMAGE_GENERATION_ENDPOINT = '/webhook/codex/image_generator';
private readonly GET_IMAGES_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_images'; private readonly GET_IMAGES_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_images';
private readonly DELETE_IMAGE_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_images'; private readonly DELETE_IMAGE_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_images';
/** private resolveEmail(userEmail?: string): string {
* Gera uma imagem a partir de uma descrição em texto return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
*
* @param request - Dados da requisição (descrição, tamanho, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResponse> {
const { description, size, userEmail, estabelecimentoId } = request;
// Usa valores do .env se não forem fornecidos
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
// Valida a descrição
if (!description || description.trim().length === 0) {
throw {
success: false,
message: 'A descrição não pode estar vazia',
};
} }
// Valida o tamanho private toApiError(error: unknown, fallback: string): never {
if (!this.isValidSize(size)) { const e = error as ApiErrorShape;
throw { throw { success: false, message: e?.message || fallback, status: e?.status };
success: false,
message: `Tamanho inválido. Opções disponíveis: ${Object.keys(IMAGE_SIZE_OPTIONS).join(', ')}`,
};
} }
// Log para debug (remover em produção se necessário) private extractArray<T>(data: unknown, keys: string[]): T[] {
console.log('Gerando imagem:', { if (Array.isArray(data)) return data as T[];
descriptionLength: description.length, if (data && typeof data === 'object') {
size, for (const key of keys) {
userEmail: email, const candidate = (data as Record<string, unknown>)[key];
estabelecimentoId: estabId, if (Array.isArray(candidate)) return candidate as T[];
});
try {
// Faz a requisição usando o serviço de API
// Nota: O campo no body é "estabelecito_id" (com typo na API)
const response = await apiService.post<ImageGenerationResponse>(
this.IMAGE_GENERATION_ENDPOINT,
{
estabelecimento_id: estabId, // Mantém o typo da API original
user_email: email,
description: description,
size: size,
}
);
// Verifica se a resposta indica erro
if (!response.data.success) {
throw {
success: false,
message: response.data.message || 'Erro ao gerar imagem',
code: response.data.code,
};
}
return response.data;
} catch (error: any) {
// Trata erros específicos da API
console.error('Erro na geração de imagem:', error);
// Se o erro já tem a estrutura esperada (veio da validação acima), repassa
if (error.success === false && error.message) {
throw error;
}
// Se o erro veio da requisição HTTP, tenta extrair a resposta da API
if (error.response?.data) {
const apiError = error.response.data;
throw {
success: false,
message: apiError.message || 'Erro ao gerar imagem',
code: apiError.code,
};
}
// Erro genérico
throw {
success: false,
message: error.message || 'Erro ao gerar imagem. Tente novamente.',
status: error.status,
};
} }
} }
return [];
}
/**
* Valida se o tamanho selecionado é suportado
*
* @param size - Tamanho a ser validado
* @returns true se o tamanho é válido
*/
isValidSize(size: string): size is ImageSize { isValidSize(size: string): size is ImageSize {
return Object.keys(IMAGE_SIZE_OPTIONS).includes(size); return Object.keys(IMAGE_SIZE_OPTIONS).includes(size);
} }
/**
* Obtém informações sobre um tamanho específico
*
* @param size - Tamanho da imagem
* @returns Informações do tamanho
*/
getSizeInfo(size: ImageSize) { getSizeInfo(size: ImageSize) {
return IMAGE_SIZE_OPTIONS[size]; return IMAGE_SIZE_OPTIONS[size];
} }
/**
* Lista todos os tamanhos disponíveis
*
* @returns Array com todas as opções de tamanho
*/
getAllSizes() { getAllSizes() {
return Object.entries(IMAGE_SIZE_OPTIONS).map(([key, info]) => ({ return Object.entries(IMAGE_SIZE_OPTIONS).map(([key, info]) => ({
value: key as ImageSize, value: key as ImageSize,
@@ -208,213 +104,98 @@ class ImageGenerationService {
})); }));
} }
/** validateDescription(description: string, minLength = 3, maxLength = 1000): { valid: boolean; error?: string } {
* Valida a descrição para geração de imagem if (!description?.trim()) return { valid: false, error: 'A descrição não pode estar vazia' };
* if (description.trim().length < minLength) return { valid: false, error: `A descrição deve ter pelo menos ${minLength} caracteres` };
* @param description - Descrição a ser validada if (description.length > maxLength) return { valid: false, error: `A descrição é muito longa. Máximo: ${maxLength} caracteres` };
* @param minLength - Comprimento mínimo (padrão: 3 caracteres)
* @param maxLength - Comprimento máximo (padrão: 1000 caracteres)
* @returns Objeto com resultado da validação
*/
validateDescription(
description: string,
minLength: number = 3,
maxLength: number = 1000
): { valid: boolean; error?: string } {
if (!description || description.trim().length === 0) {
return {
valid: false,
error: 'A descrição não pode estar vazia',
};
}
if (description.trim().length < minLength) {
return {
valid: false,
error: `A descrição deve ter pelo menos ${minLength} caracteres`,
};
}
if (description.length > maxLength) {
return {
valid: false,
error: `A descrição é muito longa. Máximo: ${maxLength} caracteres`,
};
}
return { valid: true }; return { valid: true };
} }
/** async generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResponse> {
* Faz download de uma imagem gerada const { description, size } = request;
*
* @param imageUrl - URL da imagem if (!description?.trim()) {
* @param filename - Nome do arquivo (opcional) throw { success: false, message: 'A descrição não pode estar vazia' };
*/ }
if (!this.isValidSize(size)) {
throw { success: false, message: `Tamanho inválido. Opções disponíveis: ${Object.keys(IMAGE_SIZE_OPTIONS).join(', ')}` };
}
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
try {
const response = await apiService.post<ImageGenerationResponse>(
this.IMAGE_GENERATION_ENDPOINT,
{ estabelecimento_id: estabId, user_email: email, description, size }
);
if (!response.data.success) {
throw { success: false, message: response.data.message || 'Erro ao gerar imagem', code: response.data.code };
}
return response.data;
} catch (error: unknown) {
const e = error as ApiErrorShape;
if (e?.success === false && e?.message) throw error;
console.error('Erro na geração de imagem:', error);
this.toApiError(error, 'Erro ao gerar imagem. Tente novamente.');
}
}
async downloadImage(imageUrl: string, filename?: string): Promise<void> { async downloadImage(imageUrl: string, filename?: string): Promise<void> {
try { try {
const response = await fetch(imageUrl); const response = await fetch(imageUrl);
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = filename || `imagem_${Date.now()}.png`; a.download = filename || `imagem_${Date.now()}.png`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch {
console.error('Erro ao baixar imagem:', error);
throw new Error('Não foi possível baixar a imagem'); throw new Error('Não foi possível baixar a imagem');
} }
} }
/** async getImages(userEmail?: string, page = 1, perPage = 10): Promise<ImageRecord[]> {
* Lista imagens do banco de dados com paginação const email = this.resolveEmail(userEmail);
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com a resposta paginada
*/
async getImages(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<ImageRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) { if (!email) {
throw { throw { success: false, message: 'Email do usuário não fornecido' };
success: false,
message: 'Email do usuário não fornecido',
};
} }
console.log('Buscando imagens:', {
userEmail: email,
page,
perPage,
});
try { try {
// Faz a requisição GET com parâmetros na URL e query const response = await apiService.get<unknown>(
const response = await apiService.get<ImageRecord[]>(
`${this.GET_IMAGES_ENDPOINT}/${email}`, `${this.GET_IMAGES_ENDPOINT}/${email}`,
{ { params: { page: page.toString(), per_page: perPage.toString() } }
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
); );
return this.extractArray<ImageRecord>(response.data, ['images', 'data']);
console.log('Resposta completa da API:', response); } catch (error: unknown) {
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de imagens
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'images' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).images)) {
return (response.data as any).images;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de imagens da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar imagens:', error); console.error('Erro ao buscar imagens:', error);
this.toApiError(error, 'Erro ao buscar imagens');
throw {
success: false,
message: error.message || 'Erro ao buscar imagens',
status: error.status,
};
} }
} }
/**
* Deleta uma imagem do banco de dados
*
* @param imageId - ID da imagem a ser deletada
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteImage(imageId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> { async deleteImage(imageId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos const email = this.resolveEmail(userEmail);
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) { if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
throw { if (!imageId) throw { success: false, message: 'ID da imagem não fornecido' };
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!imageId) {
throw {
success: false,
message: 'ID da imagem não fornecido',
};
}
console.log('Deletando imagem:', {
imageId,
userEmail: email,
});
try { try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>( const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_IMAGE_ENDPOINT}/${email}/${imageId}` `${this.DELETE_IMAGE_ENDPOINT}/${email}/${imageId}`
); );
const result = Array.isArray(response.data) ? response.data[0] : response.data;
console.log('Resposta completa do DELETE:', response); return { success: result.success ?? true, message: result.message || 'Imagem deletada com sucesso' };
console.log('response.data:', response.data); } catch (error: unknown) {
// A API pode retornar um array com um objeto: [{"success":true}]
// ou diretamente um objeto: {"success":true}
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Imagem deletada com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar imagem:', error); console.error('Erro ao deletar imagem:', error);
this.toApiError(error, 'Erro ao deletar imagem');
throw {
success: false,
message: error.message || 'Erro ao deletar imagem',
status: error.status,
};
} }
} }
} }
// Exporta instância única (Singleton)
export const imageGenerationService = new ImageGenerationService(); export const imageGenerationService = new ImageGenerationService();
+25 -4
View File
@@ -1,12 +1,33 @@
/**
* Exporta todos os serviços de API
*/
export { apiService } from './api'; export { apiService } from './api';
export { transcriptionService } from './transcription'; export { transcriptionService } from './transcription';
export { audioGenerationService, VOICE_OPTIONS } from './audioGeneration'; export { audioGenerationService, VOICE_OPTIONS } from './audioGeneration';
export { imageGenerationService, IMAGE_SIZE_OPTIONS } from './imageGeneration'; export { imageGenerationService, IMAGE_SIZE_OPTIONS } from './imageGeneration';
export { personalAgent } from './personalAgent';
export { asanaService } from './asana';
export { areasService } from './areas';
export { parecerService } from './parecerApi';
export { promptsService } from './promptsApi';
export { assistentePromptsService } from './assistentePrompts';
export type { TranscriptionResponse, TranscriptionRequest } from './transcription'; export type { TranscriptionResponse, TranscriptionRequest } from './transcription';
export type { AudioGenerationResponse, AudioGenerationRequest, VoiceType } from './audioGeneration'; export type { AudioGenerationResponse, AudioGenerationRequest, VoiceType } from './audioGeneration';
export type { ImageGenerationResponse, ImageGenerationRequest, ImageSize } from './imageGeneration'; export type { ImageGenerationResponse, ImageGenerationRequest, ImageSize } from './imageGeneration';
export type {
UserProfile,
CreateUserRequest,
UpdateUserRequest,
FinancialIndicators,
ExpenseItem,
ExpensesResponse,
ExpensesFilters,
ExpenseCategory
} from './personalAgent';
export type {
AsanaWorkspace,
AsanaWorkspaceResponse,
AsanaWorkspacesResponse,
AsanaUser,
AsanaUserResponse,
AsanaUsersResponse,
AsanaIntegrationResponse
} from './asana';
export type { ApiResponse, UserConfig, ApiError } from './types'; export type { ApiResponse, UserConfig, ApiError } from './types';
+423
View File
@@ -0,0 +1,423 @@
import { apiService } from "./api";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
export interface GerarParecerParams {
titulo: string;
area_id: string;
prompt_id?: string;
prompt: string;
instrucao?: string;
anexo?: File | null;
}
export interface GerarParecerSuccessResponse {
success: true;
id: string;
titulo: string;
conteudo_gerado: string;
status: string;
}
export interface GerarParecerErrorResponse {
success: false;
message?: string;
}
export type GerarParecerResponse = GerarParecerSuccessResponse | GerarParecerErrorResponse;
const PARECER_WEBHOOK_BASE = "https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer";
/** GET por id (detalhe / edição visualização) */
const PARECER_DETALHE_URL = (id: string) =>
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/parecer/${encodeURIComponent(id)}`;
const PARECER_CHAT_URL = (parecerId: string) =>
`${PARECER_DETALHE_URL(parecerId)}/chat`;
const PARECER_CHAT_ENVIAR_URL = (parecerId: string) =>
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/1ce1f771-fe6c-4905-9dbb-a69826d72632/codex/parecer/chat/${encodeURIComponent(parecerId)}`;
const PARECER_CHAT_MSG_HISTORICO_URL = (messageId: string) =>
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/parecer/chat/${encodeURIComponent(messageId)}/historico`;
export interface ParecerChatHistoricoItem {
id: string;
parecer_id: string;
role: string;
parecer_anterior: string;
parecer_atual: string;
created_at: string;
}
export interface ParecerChatMessage {
id: string;
parecer_id: string;
user_email: string;
role: "user" | "assistant";
content: string;
created_at: string;
tipo_resposta?: "chat" | "agente" | null;
}
export type ParecerChatModo = "chat" | "agente";
export type EnviarMensagemChatResposta =
| { message: string }
| { conteudo_gerado: string };
export interface ParecerDetalhe {
id: string;
estabelecimento_id: number;
user_email: string;
titulo: string;
area_id: string;
/** Nome da área vindo da API (evita depender só do cache de áreas no front). */
area_nome?: string | null;
prompt_id?: string | null;
prompt_conteudo: string;
instrucao: string;
conteudo_gerado: string;
anexo_url: string | null;
anexo_nome: string | null;
status: string;
created_at: string;
updated_at: string;
criado_por: string | null;
atualizado_por: string | null;
}
/** Extrai o objeto de detalhe quando a API devolve array, objeto com `data`, ou mistura. */
function extrairParecerDetalheRaw(raw: unknown): Record<string, unknown> | null {
if (raw == null) return null;
if (Array.isArray(raw)) {
const first = raw[0];
if (first == null) return null;
if (typeof first === "object" && !Array.isArray(first)) {
const o = first as Record<string, unknown>;
if (Array.isArray(o.data)) {
const row = o.data[0];
return row && typeof row === "object" && !Array.isArray(row) ? (row as Record<string, unknown>) : null;
}
if ("id" in o) return o;
}
return null;
}
if (typeof raw === "object") {
const o = raw as Record<string, unknown>;
if (Array.isArray(o.data)) {
const row = o.data[0];
return row && typeof row === "object" && !Array.isArray(row) ? (row as Record<string, unknown>) : null;
}
if (o.data != null && typeof o.data === "object" && !Array.isArray(o.data) && "id" in (o.data as object)) {
return o.data as Record<string, unknown>;
}
if ("id" in o) return o;
}
return null;
}
function normalizarParecerDetalhe(row: Record<string, unknown>): ParecerDetalhe {
return {
id: String(row.id ?? ""),
estabelecimento_id: Number(row.estabelecimento_id ?? 0),
user_email: String(row.user_email ?? ""),
titulo: String(row.titulo ?? ""),
area_id: String(row.area_id ?? ""),
area_nome: row.area_nome != null ? String(row.area_nome) : null,
prompt_id: row.prompt_id != null ? String(row.prompt_id) : null,
prompt_conteudo: String(row.prompt_conteudo ?? ""),
instrucao: String(row.instrucao ?? ""),
conteudo_gerado: String(row.conteudo_gerado ?? ""),
anexo_url: row.anexo_url != null ? String(row.anexo_url) : null,
anexo_nome: row.anexo_nome != null ? String(row.anexo_nome) : null,
status: String(row.status ?? ""),
created_at: String(row.created_at ?? ""),
updated_at: String(row.updated_at ?? ""),
criado_por: row.criado_por != null ? String(row.criado_por) : null,
atualizado_por: row.atualizado_por != null ? String(row.atualizado_por) : null,
};
}
export interface EditarParecerTituloAreaBody {
titulo: string;
area_id: string;
}
export interface EditarParecerTituloAreaSuccessResponse {
success: true;
id: string;
titulo: string;
area_id: string;
}
export interface EditarParecerTituloAreaErrorResponse {
success: false;
message?: string;
}
export type ParecerStatus = "processando" | "concluido" | "erro";
export interface ParecerListItem {
id: string;
titulo: string;
prompt_id: string;
area_id: string;
status: string;
area_nome: string;
prompt_nome: string;
criado_por: string | null;
created_at: string;
updated_at: string;
}
export interface ListarParecerParams {
titulo?: string;
page?: number;
per_page?: number;
area_id?: string;
status?: ParecerStatus;
}
export interface ListarParecerResponse {
success: boolean;
total_registros: number;
total_paginas: number;
per_page: number;
pagina_atual: number;
data: ParecerListItem[];
}
class ParecerService {
async gerar(params: GerarParecerParams): Promise<GerarParecerSuccessResponse> {
if (!params.titulo?.trim()) {
throw new Error("Título é obrigatório");
}
if (!params.area_id?.trim()) {
throw new Error("Área é obrigatória");
}
if (!params.prompt?.trim()) {
throw new Error("Prompt é obrigatório");
}
const userEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
const estabelecimentoId = GlobalFunctions.getTransferProperty(
TransferAreaProperties.EstabelecimentoCodigo
);
if (!userEmail) {
throw new Error("Usuário não identificado (user_email)");
}
if (!estabelecimentoId) {
throw new Error("Estabelecimento não identificado (estabelecimento_id)");
}
const form = new FormData();
form.append("user_email", String(userEmail));
form.append("estabelecimento_id", String(estabelecimentoId));
form.append("titulo", params.titulo.trim());
form.append("area_id", params.area_id.trim());
if (params.prompt_id?.trim()) {
form.append("prompt_id", params.prompt_id.trim());
}
form.append("prompt", params.prompt.trim());
form.append("instrucao", (params.instrucao ?? "").trim());
if (params.anexo) {
form.append("anexo", params.anexo);
}
// Sem limite de tempo: a geração pode levar vários minutos até o webhook responder.
const response = await apiService.postFormData<GerarParecerResponse>(
`${PARECER_WEBHOOK_BASE}/gerar`,
form,
{ timeout: 0 }
);
if (response.data.success === false) {
throw new Error(
(response.data as GerarParecerErrorResponse).message ?? "Erro ao gerar parecer"
);
}
const data = response.data as GerarParecerSuccessResponse;
if (data.status !== "concluido") {
throw new Error(
"A geração do parecer ainda não foi concluída. Tente novamente em instantes."
);
}
return data;
}
async listar(params: ListarParecerParams = {}): Promise<ListarParecerResponse> {
const query: Record<string, string | number> = {};
if (params.titulo?.trim()) query.titulo = params.titulo.trim();
if (params.page != null) query.page = params.page;
if (params.per_page != null) query.per_page = params.per_page;
if (params.area_id?.trim()) query.area_id = params.area_id.trim();
if (params.status?.trim()) query.status = params.status;
const response = await apiService.get<ListarParecerResponse[] | ListarParecerResponse>(
`${PARECER_WEBHOOK_BASE}/listar`,
{ params: query }
);
// API retorna um array cujo primeiro elemento é o objeto { success, total_registros, data, ... }
const raw = response.data;
const payload = Array.isArray(raw) ? raw[0] : raw;
if (!payload || !(payload as ListarParecerResponse).success) {
throw new Error("Erro ao listar pareceres");
}
return payload as ListarParecerResponse;
}
async buscarPorId(id: string): Promise<ParecerDetalhe> {
if (!id?.trim()) {
throw new Error("ID do parecer é obrigatório");
}
const response = await apiService.get<unknown>(PARECER_DETALHE_URL(id));
const row = extrairParecerDetalheRaw(response.data);
if (!row?.id) {
throw new Error("Parecer não encontrado");
}
return normalizarParecerDetalhe(row);
}
async enviarMensagemChat(
parecerId: string,
messageText: string,
modo: ParecerChatModo = "agente"
): Promise<EnviarMensagemChatResposta> {
const userEmail = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!userEmail) {
throw new Error("Usuário não identificado (user_email)");
}
if (!parecerId?.trim()) {
throw new Error("ID do parecer é obrigatório");
}
if (!messageText?.trim()) {
throw new Error("Digite uma mensagem");
}
type ChatPostPayload = { success: boolean; conteudo_gerado?: string; message?: string };
const response = await apiService.post<ChatPostPayload[] | ChatPostPayload>(
PARECER_CHAT_ENVIAR_URL(parecerId),
{ user_email: String(userEmail), message: messageText.trim(), modo },
{ timeout: 0, headers: { "Content-Type": "application/json" } }
);
const raw = response.data;
const payload = Array.isArray(raw) ? raw[0] : raw;
if (!payload?.success) {
throw new Error(payload?.message ?? "Erro ao processar a mensagem do chat");
}
if (modo === "chat") {
if (typeof payload.message !== "string") {
throw new Error(payload?.message ?? "Erro ao processar resposta do chat");
}
return { message: payload.message };
}
// modo === "agente"
if (typeof payload.conteudo_gerado !== "string") {
throw new Error(payload?.message ?? "Erro ao processar atualização do parecer");
}
return { conteudo_gerado: payload.conteudo_gerado };
}
async listarChat(parecerId: string): Promise<ParecerChatMessage[]> {
if (!parecerId?.trim()) {
return [];
}
const response = await apiService.get<
Array<{ success: boolean; data?: ParecerChatMessage[] }> | { success: boolean; data?: ParecerChatMessage[] }
>(PARECER_CHAT_URL(parecerId));
const raw = response.data;
const payload = Array.isArray(raw) ? raw[0] : raw;
if (!payload?.success || !Array.isArray(payload.data)) {
return [];
}
return payload.data;
}
async buscarHistoricoChatMensagem(messageId: string): Promise<ParecerChatHistoricoItem> {
if (!messageId?.trim()) {
throw new Error("ID da mensagem é obrigatório");
}
const response = await apiService.get<
Array<{ success: boolean; data?: ParecerChatHistoricoItem[] }> | { success: boolean; data?: ParecerChatHistoricoItem[] }
>(PARECER_CHAT_MSG_HISTORICO_URL(messageId));
const raw = response.data;
const payload = Array.isArray(raw) ? raw[0] : raw;
const item = payload?.data?.[0];
if (!payload?.success || !item) {
throw new Error("Não foi possível carregar o histórico desta resposta.");
}
return item;
}
async editarTituloArea(
parecerId: string,
body: EditarParecerTituloAreaBody
): Promise<EditarParecerTituloAreaSuccessResponse> {
if (!parecerId?.trim()) {
throw new Error("ID do parecer é obrigatório");
}
if (!body?.titulo?.trim()) {
throw new Error("Título é obrigatório");
}
if (!body?.area_id?.trim()) {
throw new Error("Área é obrigatória");
}
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/parecer/${encodeURIComponent(
parecerId
)}/editar`;
const response = await apiService.put<
| EditarParecerTituloAreaSuccessResponse
| EditarParecerTituloAreaErrorResponse
| (EditarParecerTituloAreaSuccessResponse | EditarParecerTituloAreaErrorResponse)[]
>(url, {
titulo: body.titulo.trim(),
area_id: body.area_id.trim(),
});
const raw = response.data;
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data || typeof data !== "object" || !("success" in data)) {
throw new Error("Resposta inválida ao editar o parecer");
}
if (data.success === false) {
throw new Error((data as EditarParecerTituloAreaErrorResponse).message ?? "Erro ao editar parecer");
}
return data as EditarParecerTituloAreaSuccessResponse;
}
async excluir(id: string): Promise<{ message: string }> {
if (!id?.trim()) {
throw new Error("ID do parecer é obrigatório");
}
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/4e6c2374-9c22-4c81-b558-45f0cfefa5c3/codex/parecer/deletar/${encodeURIComponent(id)}`;
const response = await apiService.delete<{ success: boolean; message?: string }>(url);
const body = response.data;
if (!body?.success) {
throw new Error(body?.message ?? "Erro ao excluir parecer");
}
return { message: body.message ?? "Parecer excluído com sucesso." };
}
}
export const parecerService = new ParecerService();
+238
View File
@@ -0,0 +1,238 @@
import axios from 'axios';
import { GlobalFunctions } from '@/GlobalFunctions';
export interface UserProfile {
success: boolean;
id?: string;
nome: string;
email: string;
whatsapp: string;
followup: boolean;
message?: string;
}
export interface CreateUserRequest {
nome: string;
email: string;
whatsapp: string;
followup: boolean;
}
export interface UpdateUserRequest {
nome: string;
whatsapp: string;
followup: boolean;
}
export interface FinancialIndicators {
success: boolean;
id: string;
nome: string;
email: string;
total_despesas: string;
total_corporativo: string;
total_pessoal: string;
message?: string;
}
export interface ExpenseItem {
id: string;
data_hora: string;
descricao: string;
tipo: 'pessoal' | 'corporativo';
valor: string;
categoria_id: number;
categoria_nome: string;
usuario_nome: string;
usuario_email: string;
}
export interface ExpensesResponse {
success: boolean;
total_registros: number;
total_paginas: number;
per_page: number;
pagina_atual: number;
data: ExpenseItem[];
}
export interface ExpensesFilters {
page?: number;
per_page?: number;
descricao?: string;
categoria_id?: number;
data_inicial?: string;
data_final?: string;
}
export interface ExpenseCategory {
id: number;
nome: string;
descricao: string;
criado_em: string;
atualizado_em: string;
}
type AxiosLikeError = { response?: { data?: unknown; status?: number } };
class PersonalAgent {
private async buildHeaders(): Promise<Record<string, string>> {
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiKey) headers['apikey'] = apiKey;
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
private resolveEmail(userEmail?: string): string {
return userEmail || GlobalFunctions.getUsuarioLogado().email;
}
private handleError(error: unknown, fallback: string): never {
const e = error as AxiosLikeError;
if (e?.response?.data) throw e.response.data;
throw {
success: false,
message: error instanceof Error ? error.message : fallback,
status: e?.response?.status,
};
}
async getUserProfile(userEmail?: string): Promise<UserProfile> {
const email = this.resolveEmail(userEmail);
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
try {
const headers = await this.buildHeaders();
const response = await axios.get<UserProfile>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/user/${email}`,
{ headers }
);
return response.data;
} catch (error: unknown) {
const e = error as AxiosLikeError;
if (e?.response?.data) return e.response.data as UserProfile;
throw { success: false, message: error instanceof Error ? error.message : 'Erro ao buscar perfil do usuário', status: e?.response?.status };
}
}
async createUser(request: CreateUserRequest): Promise<UserProfile> {
if (!request.nome?.trim()) throw { success: false, message: 'Nome é obrigatório' };
if (!request.email?.trim()) throw { success: false, message: 'Email é obrigatório' };
if (!request.whatsapp?.trim()) throw { success: false, message: 'WhatsApp é obrigatório' };
try {
const headers = await this.buildHeaders();
const response = await axios.post<UserProfile>(
'https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/user',
{
nome: request.nome.trim(),
email: request.email.trim(),
whatsapp: request.whatsapp.replace(/\D/g, ''),
followup: request.followup,
},
{ headers }
);
return response.data;
} catch (error: unknown) {
this.handleError(error, 'Erro ao criar usuário');
}
}
async updateUser(userId: string, request: UpdateUserRequest): Promise<UserProfile> {
if (!userId?.trim()) throw { success: false, message: 'ID do usuário é obrigatório' };
if (!request.nome?.trim()) throw { success: false, message: 'Nome é obrigatório' };
if (!request.whatsapp?.trim()) throw { success: false, message: 'WhatsApp é obrigatório' };
try {
const headers = await this.buildHeaders();
const response = await axios.post<UserProfile>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/user/${userId}`,
{
nome: request.nome.trim(),
whatsapp: request.whatsapp.replace(/\D/g, ''),
followup: request.followup,
},
{ headers }
);
return response.data;
} catch (error: unknown) {
this.handleError(error, 'Erro ao atualizar usuário');
}
}
async getFinancialIndicators(userEmail?: string): Promise<FinancialIndicators> {
const email = this.resolveEmail(userEmail);
if (!email) throw { success: false, message: 'Email do usuário é obrigatório' };
try {
const headers = await this.buildHeaders();
const response = await axios.get<FinancialIndicators>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/financas/indicadores/${email}`,
{ headers }
);
return response.data;
} catch (error: unknown) {
this.handleError(error, 'Erro ao buscar indicadores financeiros');
}
}
async getExpenses(userEmail?: string, filters?: ExpensesFilters): Promise<ExpensesResponse> {
const email = this.resolveEmail(userEmail);
if (!email) throw { success: false, message: 'Email do usuário é obrigatório' };
const empty: ExpensesResponse = {
success: true,
total_registros: 0,
total_paginas: 0,
per_page: filters?.per_page || 10,
pagina_atual: filters?.page || 1,
data: [],
};
try {
const headers = await this.buildHeaders();
const params = new URLSearchParams();
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.per_page) params.append('per_page', filters.per_page.toString());
if (filters?.descricao) params.append('descricao', filters.descricao);
if (filters?.categoria_id) params.append('categoria_id', filters.categoria_id.toString());
if (filters?.data_inicial) params.append('data_inicial', filters.data_inicial);
if (filters?.data_final) params.append('data_final', filters.data_final);
const qs = params.toString();
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/financas/${email}${qs ? `?${qs}` : ''}`;
const response = await axios.get<ExpensesResponse[]>(url, { headers });
if (!Array.isArray(response.data) || response.data.length === 0) return empty;
const result = response.data[0];
result.data = Array.isArray(result.data) ? result.data.filter((item) => item && item.id) : [];
if (result.data.length === 0) {
result.total_registros = 0;
result.total_paginas = 0;
}
return result;
} catch (error: unknown) {
this.handleError(error, 'Erro ao buscar despesas');
}
}
async getCategories(): Promise<ExpenseCategory[]> {
try {
const headers = await this.buildHeaders();
const response = await axios.get<ExpenseCategory[]>(
'https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/categorias',
{ headers }
);
return Array.isArray(response.data) ? response.data : [];
} catch (error: unknown) {
console.error('Erro ao buscar categorias:', error);
return [];
}
}
}
export const personalAgent = new PersonalAgent();
+223
View File
@@ -0,0 +1,223 @@
import { apiService } from "./api";
export interface PromptItem {
id: string;
titulo: string;
descricao: string;
conteudo: string;
area_id: string;
area_nome: string;
created_at: string;
updated_at: string;
}
export interface ListarPromptsParams {
titulo?: string;
area_id?: string;
page: number;
per_page: number;
}
export interface ListarPromptsResponseBody {
success: true;
total_registros: number;
total_paginas: number;
per_page: number;
pagina_atual: number;
data: PromptItem[];
}
export interface ListarPromptsPorAreaResponse {
success: boolean;
quantidade?: number;
data: PromptItem[];
}
export interface CriarPromptBody {
titulo: string;
descricao: string;
area_id: string;
conteudo: string;
}
export interface CriarPromptSuccessResponse {
success: true;
id: string;
titulo: string;
descricao: string;
conteudo: string;
}
export interface CriarPromptErrorResponse {
success: false;
missing_fields?: string[];
}
export interface EditarPromptBody {
titulo: string;
descricao: string;
area_id: string;
conteudo: string;
}
export interface EditarPromptSuccessResponse {
success: true;
id: string;
titulo: string;
descricao: string;
area_id: string;
conteudo: string;
}
export interface EditarPromptErrorResponse {
success: false;
missing_fields?: string[];
}
export interface DeletarPromptSuccessResponse {
success: true;
message: string;
}
export interface DeletarPromptErrorResponse {
success: false;
message?: string;
}
class PromptsService {
async listar(params: ListarPromptsParams): Promise<ListarPromptsResponseBody> {
const { page, per_page, titulo, area_id } = params;
const response = await apiService.get<ListarPromptsResponseBody[]>(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/prompts",
{
params: {
...(titulo != null && titulo.trim() !== "" ? { titulo: titulo.trim() } : {}),
...(area_id != null && area_id.trim() !== "" ? { area_id: area_id.trim() } : {}),
page,
per_page,
},
}
);
const first = Array.isArray(response.data) ? response.data[0] : response.data;
if (!first || first.success !== true) {
throw new Error("Resposta inválida ao listar prompts");
}
return first;
}
async listarPorArea(areaId: string): Promise<ListarPromptsPorAreaResponse> {
if (!areaId?.trim()) {
return { success: true, quantidade: 0, data: [] };
}
const areaIdEnc = encodeURIComponent(areaId.trim());
const response = await apiService.get<ListarPromptsPorAreaResponse | ListarPromptsPorAreaResponse[]>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/prompts/listar-todos-area?area_id=${areaIdEnc}`
);
const first = Array.isArray(response.data) ? response.data[0] : response.data;
if (!first || first.success !== true) {
throw new Error("Resposta inválida ao listar prompts da área");
}
const list = first.data ?? [];
return { success: true, quantidade: first.quantidade ?? list.length, data: list };
}
async criar(body: CriarPromptBody): Promise<CriarPromptSuccessResponse> {
if (!body.titulo?.trim() || !body.area_id?.trim() || !body.conteudo?.trim()) {
throw new Error("Título, área e conteúdo são obrigatórios");
}
const response = await apiService.post<CriarPromptSuccessResponse | CriarPromptErrorResponse>(
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/prompt/criar",
{
titulo: body.titulo.trim(),
descricao: (body.descricao ?? "").trim(),
area_id: body.area_id.trim(),
conteudo: body.conteudo.trim(),
}
);
if (response.data.success === false) {
const err = response.data as CriarPromptErrorResponse;
const msg = err.missing_fields?.length
? `Preencha: ${err.missing_fields.join(", ")}`
: "Erro ao criar prompt.";
throw new Error(msg);
}
return response.data as CriarPromptSuccessResponse;
}
async editar(id: string, body: EditarPromptBody): Promise<EditarPromptSuccessResponse> {
if (!id?.trim()) {
throw new Error("ID do prompt é obrigatório");
}
const idEnc = encodeURIComponent(id.trim());
/** Mesmo prefixo de webhook n8n usado em edição de área (`areas.editar`). */
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/parecer/prompt/${idEnc}`;
const response = await apiService.put<
EditarPromptSuccessResponse | EditarPromptErrorResponse | (EditarPromptSuccessResponse | EditarPromptErrorResponse)[]
>(url, {
titulo: (body.titulo ?? "").trim(),
descricao: (body.descricao ?? "").trim(),
area_id: body.area_id.trim(),
conteudo: (body.conteudo ?? "").trim(),
});
const raw = response.data;
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data || typeof data !== "object" || !("success" in data)) {
throw new Error("Resposta inválida ao editar prompt");
}
if (data.success === false) {
const err = data as EditarPromptErrorResponse;
const msg = err.missing_fields?.length
? `Preencha: ${err.missing_fields.join(", ")}`
: "Erro ao editar prompt.";
throw new Error(msg);
}
return data as EditarPromptSuccessResponse;
}
async deletar(id: string): Promise<DeletarPromptSuccessResponse> {
if (!id?.trim()) {
throw new Error("ID do prompt é obrigatório");
}
const idEnc = encodeURIComponent(id.trim());
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/4e6c2374-9c22-4c81-b558-45f0cfefa5c3/codex/parecer/prompt/deletar/${idEnc}`;
const response = await apiService.delete<
DeletarPromptSuccessResponse | DeletarPromptErrorResponse | (DeletarPromptSuccessResponse | DeletarPromptErrorResponse)[]
>(url);
const raw = response.data;
const data = Array.isArray(raw) ? raw[0] : raw;
if (!data || typeof data !== "object" || !("success" in data)) {
throw new Error("Resposta inválida ao excluir prompt");
}
if (data.success === false) {
const err = data as DeletarPromptErrorResponse;
throw new Error(err.message ?? "Erro ao excluir prompt.");
}
return data as DeletarPromptSuccessResponse;
}
}
export const promptsService = new PromptsService();
+58 -202
View File
@@ -1,9 +1,6 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api'; import { apiService } from './api';
/**
* Interface para a resposta da API de transcrição
*/
export interface TranscriptionResponse { export interface TranscriptionResponse {
success: boolean; success: boolean;
transcription_id: string; transcription_id: string;
@@ -11,9 +8,6 @@ export interface TranscriptionResponse {
message: string; message: string;
} }
/**
* Interface para uma transcrição armazenada no banco de dados
*/
export interface TranscriptionRecord { export interface TranscriptionRecord {
id: string; id: string;
user_email: string; user_email: string;
@@ -27,246 +21,108 @@ export interface TranscriptionRecord {
created_at: string; created_at: string;
} }
/**
* Interface para os dados necessários para transcrição
*/
export interface TranscriptionRequest { export interface TranscriptionRequest {
audioFile: File; audioFile: File;
userEmail?: string; userEmail?: string;
estabelecimentoId?: number; estabelecimentoId?: number;
} }
/** type ApiErrorShape = { message?: string; status?: number };
* Serviço de transcrição de áudio
*/
class TranscriptionService { class TranscriptionService {
private readonly TRANSCRIPTION_ENDPOINT = '/webhook/codex/transcrever_audio'; private readonly TRANSCRIPTION_ENDPOINT = '/webhook/codex/transcrever_audio';
private readonly GET_TRANSCRIPTIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_transcrever_audio'; private readonly GET_TRANSCRIPTIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_transcrever_audio';
private readonly DELETE_TRANSCRIPTION_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_transcrever_audio'; private readonly DELETE_TRANSCRIPTION_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_transcrever_audio';
/** private readonly SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
* Transcreve um arquivo de áudio
* private resolveEmail(userEmail?: string): string {
* @param request - Dados da requisição (arquivo, email, estabelecimento) return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
* @returns Promise com a resposta da API }
*/
async transcribeAudio(request: TranscriptionRequest): Promise<TranscriptionResponse> { private toApiError(error: unknown, fallback: string): never {
const { audioFile, userEmail, estabelecimentoId } = request; const e = error as ApiErrorShape;
throw { success: false, message: e?.message || fallback, status: e?.status };
}
private extractArray<T>(data: unknown, keys: string[]): T[] {
if (Array.isArray(data)) return data as T[];
if (data && typeof data === 'object') {
for (const key of keys) {
const candidate = (data as Record<string, unknown>)[key];
if (Array.isArray(candidate)) return candidate as T[];
}
}
return [];
}
validateAudioFile(file: File, maxSizeMB = 25): { valid: boolean; error?: string } {
const maxBytes = maxSizeMB * 1024 * 1024;
if (file.size > maxBytes) {
return { valid: false, error: `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB` };
}
const ext = file.name.split('.').pop()?.toLowerCase();
if (!ext || !this.SUPPORTED_FORMATS.includes(ext)) {
return { valid: false, error: `Formato não suportado. Formatos aceitos: ${this.SUPPORTED_FORMATS.join(', ')}` };
}
return { valid: true };
}
async transcribeAudio(request: TranscriptionRequest): Promise<TranscriptionResponse> {
const { audioFile } = request;
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
// Cria FormData para envio multipart
const formData = new FormData(); const formData = new FormData();
formData.append('data', audioFile); formData.append('data', audioFile);
// Usa valores do .env se não forem fornecidos
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
formData.append('user_email', email); formData.append('user_email', email);
formData.append('estabelecimento_id', estabId.toString()); formData.append('estabelecimento_id', estabId.toString());
// Log para debug (remover em produção se necessário)
console.log('Enviando transcrição:', {
fileName: audioFile.name,
fileSize: audioFile.size,
fileType: audioFile.type,
userEmail: email,
estabelecimentoId: estabId,
});
try { try {
// Faz a requisição usando o serviço de API
const response = await apiService.postFormData<TranscriptionResponse>( const response = await apiService.postFormData<TranscriptionResponse>(
this.TRANSCRIPTION_ENDPOINT, this.TRANSCRIPTION_ENDPOINT,
formData formData
); );
return response.data; return response.data;
} catch (error: any) { } catch (error: unknown) {
// Trata erros específicos
console.error('Erro na transcrição:', error); console.error('Erro na transcrição:', error);
this.toApiError(error, 'Erro ao transcrever áudio');
throw {
success: false,
message: error.message || 'Erro ao transcrever áudio',
status: error.status,
};
} }
} }
/** async getTranscriptions(userEmail?: string, page = 1, perPage = 10): Promise<TranscriptionRecord[]> {
* Valida se o arquivo de áudio é suportado const email = this.resolveEmail(userEmail);
* if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
* @param file - Arquivo a ser validado
* @param maxSizeMB - Tamanho máximo em MB (padrão: 25MB)
* @returns Objeto com resultado da validação
*/
validateAudioFile(file: File, maxSizeMB: number = 25): { valid: boolean; error?: string } {
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = maxSizeMB * 1024 * 1024;
// Valida tamanho
if (file.size > MAX_FILE_SIZE) {
return {
valid: false,
error: `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`,
};
}
// Valida formato
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (!fileExtension || !SUPPORTED_FORMATS.includes(fileExtension)) {
return {
valid: false,
error: `Formato não suportado. Formatos aceitos: ${SUPPORTED_FORMATS.join(', ')}`,
};
}
return { valid: true };
}
/**
* Lista transcrições do banco de dados com paginação
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com o array de transcrições
*/
async getTranscriptions(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<TranscriptionRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando transcrições:', {
userEmail: email,
page,
perPage,
});
try { try {
// Faz a requisição GET com parâmetros na URL e query const response = await apiService.get<unknown>(
const response = await apiService.get<TranscriptionRecord[]>(
`${this.GET_TRANSCRIPTIONS_ENDPOINT}/${email}`, `${this.GET_TRANSCRIPTIONS_ENDPOINT}/${email}`,
{ { params: { page: page.toString(), per_page: perPage.toString() } }
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
); );
return this.extractArray<TranscriptionRecord>(response.data, ['transcriptions', 'data']);
console.log('Resposta completa da API:', response); } catch (error: unknown) {
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de transcrições
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'transcriptions' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).transcriptions)) {
return (response.data as any).transcriptions;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de transcrições da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar transcrições:', error); console.error('Erro ao buscar transcrições:', error);
this.toApiError(error, 'Erro ao buscar transcrições');
throw {
success: false,
message: error.message || 'Erro ao buscar transcrições',
status: error.status,
};
} }
} }
/**
* Deleta uma transcrição do banco de dados
*
* @param transcriptionId - ID da transcrição a ser deletada
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteTranscription(transcriptionId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> { async deleteTranscription(transcriptionId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos const email = this.resolveEmail(userEmail);
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail); if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
if (!transcriptionId) throw { success: false, message: 'ID da transcrição não fornecido' };
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!transcriptionId) {
throw {
success: false,
message: 'ID da transcrição não fornecido',
};
}
console.log('Deletando transcrição:', {
transcriptionId,
userEmail: email,
});
try { try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>( const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_TRANSCRIPTION_ENDPOINT}/${email}/${transcriptionId}` `${this.DELETE_TRANSCRIPTION_ENDPOINT}/${email}/${transcriptionId}`
); );
const result = Array.isArray(response.data) ? response.data[0] : response.data;
console.log('Resposta completa do DELETE:', response); return { success: result.success ?? true, message: result.message || 'Transcrição deletada com sucesso' };
console.log('response.data:', response.data); } catch (error: unknown) {
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Transcrição deletada com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar transcrição:', error); console.error('Erro ao deletar transcrição:', error);
this.toApiError(error, 'Erro ao deletar transcrição');
throw {
success: false,
message: error.message || 'Erro ao deletar transcrição',
status: error.status,
};
} }
} }
} }
// Exporta instância única (Singleton)
export const transcriptionService = new TranscriptionService(); export const transcriptionService = new TranscriptionService();
-13
View File
@@ -1,27 +1,14 @@
/**
* Tipos compartilhados para as APIs
*/
/**
* Resposta padrão de sucesso/erro da API
*/
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
success: boolean; success: boolean;
message?: string; message?: string;
data?: T; data?: T;
} }
/**
* Configuração de usuário para requisições
*/
export interface UserConfig { export interface UserConfig {
userEmail?: string; userEmail?: string;
estabelecimentoId?: number; estabelecimentoId?: number;
} }
/**
* Resposta de erro da API
*/
export interface ApiError { export interface ApiError {
success: false; success: false;
message: string; message: string;
+25
View File
@@ -0,0 +1,25 @@
/**
* Toca um som curto de sucesso (dois tons) usando Web Audio API.
* Não requer arquivos de áudio.
*/
export function playSuccessSound(): void {
try {
const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const playTone = (frequency: number, startTime: number, duration: number) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = frequency;
osc.type = "sine";
gain.gain.setValueAtTime(0.15, startTime);
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
};
playTone(523.25, 0, 0.15);
playTone(659.25, 0.18, 0.2);
} catch {
// Ignora se AudioContext não for suportado ou bloqueado (autoplay policy)
}
}
+9 -1
View File
@@ -34,6 +34,14 @@ export default {
DEFAULT: "hsl(var(--destructive))", DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))", foreground: "hsl(var(--destructive-foreground))",
}, },
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
muted: { muted: {
DEFAULT: "hsl(var(--muted))", DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))", foreground: "hsl(var(--muted-foreground))",
@@ -103,5 +111,5 @@ export default {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config; } satisfies Config;
+8
View File
@@ -8,6 +8,14 @@ export default defineConfig(({ mode }) => ({
server: { server: {
host: "::", host: "::",
port: 8080, port: 8080,
proxy: {
'/api/intelligence': {
target: 'https://prod-hgtx-intelligence-n8n.hgtx.com.br',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/intelligence/, ''),
secure: true,
},
},
}, },
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean), plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: { resolve: {