Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a01c6f97b | |||
| 8f2e4f1d21 | |||
| 8d8818c32e | |||
| 6537e2dd53 | |||
| 6bf7e7d42e | |||
| 39032ea621 | |||
| 71f9c9ab86 | |||
| d75d80e9c7 | |||
| 189a2a8f2c | |||
| 50aca767d7 | |||
| 76cbd80eef | |||
| ee7204982c | |||
| a28b780718 | |||
| 0fa382ef0e | |||
| c2aa536660 | |||
| c4b281abd5 | |||
| 45ea53b364 | |||
| 9f75feb69a | |||
| a85465e332 | |||
| 8a9096470f | |||
| c82c2166f2 | |||
| c8c2ca8c5b | |||
| 8b5c940976 | |||
| 235fb538e1 | |||
| 27820254e7 | |||
| e9f0165006 | |||
| c88342369f | |||
| 18285851ac | |||
| b1b37f5cf1 | |||
| b81e1cf47d | |||
| 9ab3530a80 | |||
| c37c854b07 | |||
| b0a1cfdab5 |
@@ -0,0 +1,20 @@
|
||||
# API Configuration
|
||||
# Base URL da API do HGTX
|
||||
VITE_API_BASE_URL=https://prod-hgtx-intelligence-n8n.hgtx.com.br
|
||||
VITE_BASE_URL_OPEN_CODEX_APIs=https://codex-api.hgtx.com.br/api/
|
||||
VITE_BASE_URL_HGTX_CORE_API=https://core-api.hgtx.com.br/api/
|
||||
VITE_BASE_URL_HGTX_CORE=https://core.hgtx.com.br/
|
||||
|
||||
# API Key para autenticação
|
||||
# IMPORTANTE: Mantenha esta chave segura e não compartilhe publicamente
|
||||
VITE_API_KEY=your-api-key-here
|
||||
|
||||
# User Configuration
|
||||
# Configure com os dados do usuário/estabelecimento
|
||||
VITE_USER_EMAIL=user@example.com
|
||||
VITE_ESTABELECIMENTO_ID=1
|
||||
|
||||
# Instruções:
|
||||
# 1. Copie este arquivo para .env
|
||||
# 2. Substitua os valores acima com as configurações reais
|
||||
# 3. NUNCA commite o arquivo .env no git
|
||||
@@ -12,6 +12,11 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@magicui": "https://magicui.design/r/{name}"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="pt-br">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -10,11 +10,11 @@
|
||||
<meta property="og:title" content="HGTX Codex - AI Interface System" />
|
||||
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
<meta property="og:image" content="/favicon.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@lovable_dev" />
|
||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
<meta name="twitter:site" content="@hgtx" />
|
||||
<meta name="twitter:image" content="/favicon.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Generated
+1678
-9
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -32,7 +32,7 @@
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@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-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
@@ -40,18 +40,24 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"axios": "^1.12.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^12.36.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
+34
-4
@@ -2,24 +2,44 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import Index from "./pages/Index";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Redirect from "./pages/Redirect";
|
||||
import { GlobalFunctions } from "./GlobalFunctions";
|
||||
import React from "react";
|
||||
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => (
|
||||
<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>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route
|
||||
path="/redir/:gsikey"
|
||||
element={<Redirect />}
|
||||
/>
|
||||
|
||||
{/* Módulo Intelligence IA como subpath*/}
|
||||
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
||||
|
||||
<Route path="/*" element={<Index />} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="/404" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
@@ -28,3 +48,13 @@ const App = () => (
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
const HGTXLogin = () =>{
|
||||
|
||||
React.useEffect(() => {
|
||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||
});
|
||||
|
||||
return(<></>);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { RedirectService } from './pages/RedirectService';
|
||||
|
||||
export class GlobalFunctions {
|
||||
|
||||
public static getTranferAreaObj(): TransferAreaResponse {
|
||||
let objRetorno: TransferAreaResponse = {
|
||||
aplicativoID: 0,
|
||||
aplicativoNome: "",
|
||||
areaTransferenciaID: 0,
|
||||
dataRegistro: "",
|
||||
estabelecimentoID: 0,
|
||||
usuarioID: 0,
|
||||
usuarioEmail: "",
|
||||
usuarioNome: "",
|
||||
aplicativoShareCode: "",
|
||||
nivelAcesso: 0
|
||||
};
|
||||
try {
|
||||
const objTransferStr = sessionStorage.getItem("TRANSFER_AREA");
|
||||
if (
|
||||
objTransferStr != null &&
|
||||
objTransferStr != undefined &&
|
||||
objTransferStr.trim() != ""
|
||||
) {
|
||||
const objTransfer = <TransferAreaResponse>JSON.parse(objTransferStr);
|
||||
objRetorno = objTransfer;
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
return objRetorno;
|
||||
}
|
||||
|
||||
public static getRedirectComponent(): string {
|
||||
let objRetorno = "";
|
||||
try {
|
||||
const objTransfer = GlobalFunctions.getTranferAreaObj();
|
||||
if (objTransfer != null) {
|
||||
// let objRedir = objTransfer.find(x => x.name == 'REDIRECT');
|
||||
// objRetorno = objRedir?.value ?? '';
|
||||
objRetorno = objTransfer.urlDestino ?? "";
|
||||
if (objRetorno.startsWith("/")) objRetorno = objRetorno.substring(1);
|
||||
} else {
|
||||
objRetorno = "";
|
||||
}
|
||||
} catch (error) {
|
||||
//console.log(error);
|
||||
objRetorno = "";
|
||||
}
|
||||
|
||||
return objRetorno;
|
||||
}
|
||||
|
||||
public static getTransferProperty(propertyName: TransferAreaProperties): any {
|
||||
let objRetorno: any = "";
|
||||
try {
|
||||
const objTransfer = this.getTranferAreaObj();
|
||||
if (objTransfer != null) {
|
||||
//let obj: any = objTransfer[propertyName];
|
||||
const element = objTransfer[propertyName];
|
||||
objRetorno = element;
|
||||
} else {
|
||||
objRetorno = null;
|
||||
}
|
||||
} catch (error) {
|
||||
objRetorno = null;
|
||||
}
|
||||
|
||||
return objRetorno;
|
||||
}
|
||||
|
||||
public static isUsuarioLogado(): boolean {
|
||||
let jsonUsuario = sessionStorage.getItem('usuarioLogado');
|
||||
if (jsonUsuario) {
|
||||
let tokenDecode: any = GlobalFunctions.getUsuarioLogado();
|
||||
let d: Date = new Date();
|
||||
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
|
||||
if (dateAtual > tokenDecode.exp) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static tokenExpired(tokenDecode: any): boolean {
|
||||
let d: Date = new Date();
|
||||
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
|
||||
if (dateAtual > tokenDecode.exp) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static handleNavigateBack(): void {
|
||||
try {
|
||||
// Verifica se existe histórico de navegação
|
||||
if (window.history && window.history.length > 1) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
// Tenta fechar a janela
|
||||
if (!window.closed) {
|
||||
try {
|
||||
const windowRef = window.open('', '_self');
|
||||
if (windowRef) {
|
||||
windowRef.close();
|
||||
|
||||
// Pequeno timeout para verificar se a janela foi fechada
|
||||
setTimeout(() => {
|
||||
// Se ainda estiver aberta, redireciona
|
||||
if (!windowRef.closed) {
|
||||
window.location.href = "https://core.hgtx.com.br/aplicativos/";
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
} catch (closeError) {
|
||||
console.warn("Não foi possível fechar a janela:", closeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Se não conseguiu fechar, redireciona
|
||||
window.location.href = "https://core.hgtx.com.br/aplicativos/";
|
||||
} catch (error) {
|
||||
// Em caso de qualquer erro, garante o redirecionamento
|
||||
console.error("Erro ao tentar navegar:", error);
|
||||
window.location.href = "https://core.hgtx.com.br/aplicativos/";
|
||||
}
|
||||
}
|
||||
|
||||
public static getUsuarioLogado(): UsuarioLogadoData {
|
||||
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
|
||||
try {
|
||||
const objJwt = JSON.parse(jsonUsuario);
|
||||
//return jwt_decode(objJwt.token);
|
||||
return jwtDecode(objJwt.token);
|
||||
} catch (error) {}
|
||||
|
||||
return {
|
||||
EID: "0",
|
||||
email: "",
|
||||
exp: 0,
|
||||
iat: 0,
|
||||
jti: "",
|
||||
name: "",
|
||||
nbf: 0,
|
||||
UID: "0",
|
||||
};
|
||||
}
|
||||
|
||||
public static getUsuarioLogadoToken(): string {
|
||||
let retorno = "";
|
||||
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
|
||||
try {
|
||||
const objJwt = JSON.parse(jsonUsuario);
|
||||
retorno = objJwt.token;
|
||||
} catch (error) {}
|
||||
|
||||
return retorno;
|
||||
}
|
||||
|
||||
public static getUsuarioLogadoRefreshToken(): string {
|
||||
let retorno = "";
|
||||
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
|
||||
try {
|
||||
const objJwt = JSON.parse(jsonUsuario);
|
||||
retorno = objJwt.refreshToken;
|
||||
} catch (error) {}
|
||||
|
||||
return retorno;
|
||||
}
|
||||
|
||||
public static getPerfilUsuario(): PerfilUsuario | null {
|
||||
let retorno: PerfilUsuario | null = null;
|
||||
const jsonUsuario = sessionStorage.getItem("perfilUsuario") ?? "";
|
||||
try {
|
||||
const objJwt = <PerfilUsuario>JSON.parse(jsonUsuario);
|
||||
retorno = objJwt;
|
||||
} catch (error) {
|
||||
retorno = null;
|
||||
}
|
||||
|
||||
return retorno;
|
||||
}
|
||||
|
||||
public static getHighUserProfile(): number {
|
||||
let retorno = 6;
|
||||
const objPerfil = GlobalFunctions.getPerfilUsuario();
|
||||
|
||||
if (objPerfil) {
|
||||
const tiposPerfil: number[] = objPerfil.results.map((v) => v.tipoPerfil);
|
||||
retorno = Math.min(...tiposPerfil);
|
||||
}
|
||||
|
||||
return retorno;
|
||||
}
|
||||
|
||||
public static async getToken(): Promise<string> {
|
||||
let token: string = GlobalFunctions.getUsuarioLogadoToken();
|
||||
|
||||
const tokenDecode: any = GlobalFunctions.getUsuarioLogado();
|
||||
|
||||
let currentDate = new Date();
|
||||
|
||||
// JWT exp is in seconds
|
||||
if ((tokenDecode.exp * 1000) < currentDate.getTime()) {
|
||||
//console.log("Token expired.");
|
||||
let gsikey: number = parseInt(sessionStorage.getItem('gsikey') ?? '0');
|
||||
RedirectService.loadTransferArea(gsikey).then((response) => {
|
||||
if(response.data.errorCode == 0){
|
||||
sessionStorage.setItem('TRANSFER_AREA',JSON.stringify(response.data.areTrs));
|
||||
sessionStorage.setItem('usuarioLogado',JSON.stringify({
|
||||
token: response.data.jwt.token,
|
||||
refreshToken: response.data.jwt.refreshToken
|
||||
}));
|
||||
|
||||
token = response.data.jwt.token;
|
||||
}
|
||||
// else if(error !== 1401){
|
||||
// navigate('/',{replace: true});
|
||||
// }
|
||||
});
|
||||
}
|
||||
// else {
|
||||
// //console.log("Valid token");
|
||||
// result = true;
|
||||
// }
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UsuarioLogadoData {
|
||||
UID: string;
|
||||
EID: string;
|
||||
name: string;
|
||||
email: string;
|
||||
jti: string;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
export interface TransferAreaResponse {
|
||||
estabelecimentoID: number;
|
||||
nivelAcesso: number;
|
||||
aplicativoID: number;
|
||||
usuarioID: number;
|
||||
areaTransferenciaID: number;
|
||||
dataRegistro: string;
|
||||
urlBase?: string;
|
||||
urlDestino?: string;
|
||||
aplicativoNome: string;
|
||||
usuarioNome: string;
|
||||
usuarioEmail: string;
|
||||
aplicativoShareCode: string;
|
||||
}
|
||||
|
||||
export enum TransferAreaProperties {
|
||||
EstabelecimentoCodigo = "estabelecimentoID",
|
||||
UsuarioCodigo = "usuarioID",
|
||||
UsuarioNome = "usuarioNome",
|
||||
UsuarioEmail = "usuarioEmail",
|
||||
APP_COD = "aplicativoID",
|
||||
APP_TITULO = "aplicativoNome",
|
||||
areaTransferenciaID = "areaTransferenciaID",
|
||||
urlBase = "urlBase",
|
||||
urlDestino = "urlDestino",
|
||||
aplicativoShareCode = "aplicativoShareCode",
|
||||
nivelAcesso = "nivelAcesso",
|
||||
}
|
||||
|
||||
type PerfilUsuario = {
|
||||
errorCode: number;
|
||||
errorMessage: string;
|
||||
results: PerfilUsuarioResult[];
|
||||
};
|
||||
|
||||
export type PerfilUsuarioResult = {
|
||||
tipoPerfil: number;
|
||||
itens: PerfilUsuarioItem[];
|
||||
};
|
||||
|
||||
type PerfilUsuarioItem = {
|
||||
id: number;
|
||||
descricao: string;
|
||||
identificador: string;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, UserCog } from "lucide-react";
|
||||
import { ChevronDown, UserCog, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchModelsFromDatabase, ModelConfig } from "@/config/models";
|
||||
|
||||
// Texto padrão da personalidade (mesmo valor usado em ChatView)
|
||||
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
showModelSelector?: boolean;
|
||||
@@ -27,20 +31,32 @@ interface ChatHeaderProps {
|
||||
|
||||
export const ChatHeader = ({
|
||||
showModelSelector = false,
|
||||
selectedModel = "ChatGPT 4.1",
|
||||
selectedModel = "GPT-4o",
|
||||
onModelChange,
|
||||
systemPrompt = "Você é um assistente útil e prestativo.",
|
||||
systemPrompt = DEFAULT_SYSTEM_PROMPT,
|
||||
onSystemPromptChange
|
||||
}: ChatHeaderProps) => {
|
||||
const [isPersonalityOpen, setIsPersonalityOpen] = useState(false);
|
||||
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
|
||||
const [models, setModels] = useState<ModelConfig[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(true);
|
||||
|
||||
const models = [
|
||||
"ChatGPT 4.1",
|
||||
"ChatGPT 5",
|
||||
"Claude Sonnet 4.5",
|
||||
"Claude Opus 4.1",
|
||||
];
|
||||
// Carrega modelos da API ao montar o componente
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
setIsLoadingModels(true);
|
||||
try {
|
||||
const modelsFromAPI = await fetchModelsFromDatabase();
|
||||
setModels(modelsFromAPI);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar modelos:', error);
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}, []);
|
||||
|
||||
const handleSavePersonality = () => {
|
||||
if (onSystemPromptChange) {
|
||||
@@ -50,8 +66,8 @@ export const ChatHeader = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" />
|
||||
<span className="text-sm text-muted-foreground">Online</span>
|
||||
@@ -60,19 +76,37 @@ export const ChatHeader = ({
|
||||
{showModelSelector && onModelChange && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2 glass-effect">
|
||||
<span className="font-medium">{selectedModel}</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
|
||||
disabled={isLoadingModels}
|
||||
>
|
||||
{isLoadingModels ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 md:w-4 md:h-4 animate-spin" />
|
||||
<span className="font-medium">Carregando...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium truncate max-w-[80px] md:max-w-none">{selectedModel}</span>
|
||||
<ChevronDown className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
|
||||
{models.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model}
|
||||
key={model.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onModelChange(model)}
|
||||
onClick={() => onModelChange(model.name)}
|
||||
>
|
||||
{model}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
{model.description && (
|
||||
<span className="text-xs text-muted-foreground">{model.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -84,11 +118,11 @@ export const ChatHeader = ({
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 glass-effect"
|
||||
className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
|
||||
onClick={() => setTempSystemPrompt(systemPrompt)}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
<span className="font-medium">Personalidade</span>
|
||||
<UserCog className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden md:inline font-medium">Personalidade</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -120,7 +154,7 @@ export const ChatHeader = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="hidden md:block text-sm text-muted-foreground">
|
||||
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface FullScreenLoaderProps {
|
||||
show: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function FullScreenLoader({ show, text = "Carregando..." }: FullScreenLoaderProps) {
|
||||
const [visible, setVisible] = useState(show);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) setVisible(true);
|
||||
else {
|
||||
// adiciona um pequeno atraso para suavizar o fade-out
|
||||
const timeout = setTimeout(() => setVisible(false), 200);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
if (!visible && !show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-200 ${
|
||||
show ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-2xl bg-card shadow-lg border border-border transition-all duration-200">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
||||
<p className="text-card-foreground text-sm font-medium">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-19
@@ -1,16 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquare, Image, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Sparkles } 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 { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
activeTab: "chat" | "images" | "audio" | "bots" | "agent";
|
||||
onTabChange: (tab: "chat" | "images" | "audio" | "bots" | "agent") => void;
|
||||
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
|
||||
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas") => void;
|
||||
}
|
||||
|
||||
type TabType = "chat" | "images" | "audio" | "bots" | "agent";
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
|
||||
|
||||
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
@@ -18,16 +19,19 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
const tabs = [
|
||||
{ id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare },
|
||||
{ id: "images" as TabType, label: "Imagens", icon: Image },
|
||||
{ id: "audio" as TabType, label: "Áudio", icon: Mic },
|
||||
{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
||||
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Sparkles },
|
||||
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
|
||||
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
|
||||
//{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
||||
{ 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 (
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
{!isSidebarCollapsed ? (
|
||||
<aside className="w-72 bg-sidebar border-r border-sidebar-border flex flex-col">
|
||||
<aside className="hidden md:flex md:w-72 bg-sidebar border-r border-sidebar-border flex-col">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-sidebar-border flex items-center justify-between">
|
||||
<div>
|
||||
@@ -74,14 +78,14 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
|
||||
<Button onClick={GlobalFunctions.handleNavigateBack} variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
<aside className="w-16 bg-sidebar border-r border-sidebar-border flex flex-col">
|
||||
<aside className="hidden md:flex md:w-16 bg-sidebar border-r border-sidebar-border flex-col">
|
||||
{/* Collapsed Header with Expand Button */}
|
||||
<div className="p-3 border-b border-sidebar-border space-y-2">
|
||||
<Button
|
||||
@@ -127,12 +131,7 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
{/* Theme & Settings Icons */}
|
||||
<div className="p-3 border-t border-sidebar-border space-y-2">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-full text-sidebar-foreground"
|
||||
title="Voltar"
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="w-full text-sidebar-foreground" title="Voltar">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -140,9 +139,29 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
<main className="flex-1 flex flex-col overflow-hidden w-full">{children}</main>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-sidebar border-t border-sidebar-border flex items-center justify-around py-2 z-50">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all ${
|
||||
isActive
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/70"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isActive ? "text-primary" : ""}`} />
|
||||
<span className="text-xs font-medium">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Download, Send, Sparkles } from "lucide-react";
|
||||
import { LegalOpinion } from "./AgentView";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface AgentChatProps {
|
||||
selectedOpinion: LegalOpinion | null;
|
||||
isCreatingNew: boolean;
|
||||
onOpinionCreated: (opinion: LegalOpinion) => void;
|
||||
}
|
||||
|
||||
export const AgentChat = ({
|
||||
selectedOpinion,
|
||||
isCreatingNew,
|
||||
onOpinionCreated,
|
||||
}: AgentChatProps) => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [generatedContent, setGeneratedContent] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!instructions.trim()) {
|
||||
toast({
|
||||
title: "Instruções necessárias",
|
||||
description: "Por favor, forneça instruções para gerar o parecer.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
// Simulação de chamada à IA - aqui você integraria com a API real
|
||||
setTimeout(() => {
|
||||
const mockContent = `PARECER JURÍDICO
|
||||
|
||||
TÍTULO: ${title || "Parecer Jurídico"}
|
||||
|
||||
${instructions}
|
||||
|
||||
ANÁLISE:
|
||||
|
||||
Este parecer foi gerado com base nas instruções fornecidas. A análise considera os seguintes aspectos legais:
|
||||
|
||||
1. Fundamentação Legal
|
||||
2. Precedentes Judiciais
|
||||
3. Doutrina Aplicável
|
||||
4. Conclusão e Recomendações
|
||||
|
||||
CONCLUSÃO:
|
||||
|
||||
Com base na análise realizada, conclui-se que...
|
||||
|
||||
___________________________
|
||||
Parecer gerado em ${new Date().toLocaleDateString('pt-BR')}`;
|
||||
|
||||
setGeneratedContent(mockContent);
|
||||
setIsGenerating(false);
|
||||
|
||||
if (isCreatingNew) {
|
||||
const newOpinion: LegalOpinion = {
|
||||
id: Date.now().toString(),
|
||||
title: title || "Novo Parecer",
|
||||
content: mockContent,
|
||||
createdAt: new Date(),
|
||||
category: category || undefined,
|
||||
};
|
||||
onOpinionCreated(newOpinion);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Parecer gerado com sucesso!",
|
||||
description: "O parecer está pronto para download.",
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleDownloadDocx = () => {
|
||||
// Aqui você implementaria a geração real do DOCX
|
||||
// Por enquanto, vamos criar um arquivo de texto
|
||||
const content = generatedContent || selectedOpinion?.content || "";
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || selectedOpinion?.title || 'parecer'}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "Download iniciado",
|
||||
description: "O parecer está sendo baixado.",
|
||||
});
|
||||
};
|
||||
|
||||
const displayContent = generatedContent || selectedOpinion?.content;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="p-6 border-b border-border">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
{selectedOpinion ? selectedOpinion.title : "Novo Parecer Jurídico"}
|
||||
</h2>
|
||||
{selectedOpinion && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Criado em {new Date(selectedOpinion.createdAt).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full p-6 space-y-4">
|
||||
{(isCreatingNew || !selectedOpinion) && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Título do Parecer</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Categoria</label>
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="Ex: Direito Civil, Trabalhista, etc."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{selectedOpinion ? "Instruções para novo modelo" : "Instruções para gerar o parecer"}
|
||||
</label>
|
||||
<Textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>Gerando parecer...</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{selectedOpinion ? "Gerar Novo Modelo com IA" : "Gerar Parecer com IA"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{displayContent && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Parecer Gerado</label>
|
||||
<Button
|
||||
onClick={handleDownloadDocx}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Baixar DOCX
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-[300px] rounded-md border border-border p-4">
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-foreground">
|
||||
{displayContent}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, FileText } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { LegalOpinion } from "./AgentView";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { agentService, OpinionRecord } from "@/services/agent";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface AgentSearchProps {
|
||||
onSelectOpinion: (opinion: LegalOpinion) => void;
|
||||
onSelectOpinion: (opinion: OpinionRecord) => void;
|
||||
}
|
||||
|
||||
// Simulação de base de pareceres
|
||||
const mockOpinionsDatabase: LegalOpinion[] = [
|
||||
{
|
||||
id: "base-1",
|
||||
title: "Análise Contratual - Prestação de Serviços Continuados",
|
||||
content: "Parecer completo sobre prestação de serviços...",
|
||||
createdAt: new Date("2024-01-15"),
|
||||
category: "Direito Civil",
|
||||
},
|
||||
{
|
||||
id: "base-2",
|
||||
title: "Rescisão de Contrato de Trabalho - Justa Causa",
|
||||
content: "Análise jurídica sobre rescisão contratual...",
|
||||
createdAt: new Date("2024-02-20"),
|
||||
category: "Direito Trabalhista",
|
||||
},
|
||||
{
|
||||
id: "base-3",
|
||||
title: "Responsabilidade Civil - Acidente de Trânsito",
|
||||
content: "Parecer sobre responsabilidade civil em acidentes...",
|
||||
createdAt: new Date("2024-03-10"),
|
||||
category: "Direito Civil",
|
||||
},
|
||||
{
|
||||
id: "base-4",
|
||||
title: "Dissolução de Sociedade - Procedimentos e Requisitos",
|
||||
content: "Análise completa sobre dissolução societária...",
|
||||
createdAt: new Date("2024-01-25"),
|
||||
category: "Direito Empresarial",
|
||||
},
|
||||
{
|
||||
id: "base-5",
|
||||
title: "Direitos do Consumidor - Vícios em Produtos",
|
||||
content: "Parecer sobre direitos do consumidor e garantias...",
|
||||
createdAt: new Date("2024-02-05"),
|
||||
category: "Direito do Consumidor",
|
||||
},
|
||||
];
|
||||
|
||||
export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<LegalOpinion[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<OpinionRecord[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSearch = () => {
|
||||
// Busca pareceres da API
|
||||
const handleSearch = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await agentService.getOpinions({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchTerm,
|
||||
});
|
||||
setSearchResults(data);
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao buscar pareceres:', error);
|
||||
toast({
|
||||
title: "Erro ao buscar pareceres",
|
||||
description: error.message || "Não foi possível buscar os pareceres.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Busca automaticamente quando a página ou itemsPerPage mudam
|
||||
useEffect(() => {
|
||||
if (searchTerm.trim()) {
|
||||
handleSearch();
|
||||
}
|
||||
}, [currentPage, itemsPerPage]);
|
||||
|
||||
// Debounce para busca automática
|
||||
useEffect(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = mockOpinionsDatabase.filter(
|
||||
(opinion) =>
|
||||
opinion.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
opinion.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
opinion.category?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
if (currentPage === 1) {
|
||||
handleSearch();
|
||||
} else {
|
||||
setCurrentPage(1); // Volta para primeira página ao buscar
|
||||
}
|
||||
}, 500);
|
||||
|
||||
setSearchResults(results);
|
||||
};
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
const totalPages = Math.ceil(searchResults.length / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
@@ -76,21 +85,28 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Digite título, frase ou categoria..."
|
||||
placeholder="Digite título ou categoria..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button onClick={handleSearch} className="gap-2">
|
||||
<Button onClick={handleSearch} className="gap-2" disabled={isLoading}>
|
||||
<Search className="w-4 h-4" />
|
||||
Buscar
|
||||
{isLoading ? 'Buscando...' : 'Buscar'}
|
||||
</Button>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Base com {mockOpinionsDatabase.length} pareceres disponíveis
|
||||
{searchResults.length} {searchResults.length === 1 ? 'parecer encontrado' : 'pareceres encontrados'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{searchResults.length > 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<p>Buscando pareceres...</p>
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{searchResults.map((opinion) => (
|
||||
<div
|
||||
@@ -101,13 +117,13 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-primary mt-1" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground">{opinion.title}</h3>
|
||||
<h3 className="font-semibold text-foreground">{opinion.titulo}</h3>
|
||||
<div className="flex gap-4 mt-2 text-sm text-muted-foreground">
|
||||
<span>{opinion.category}</span>
|
||||
<span>{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}</span>
|
||||
<span>{opinion.categoria || '-'}</span>
|
||||
<span>{new Date(opinion.created_at).toLocaleDateString('pt-BR')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{opinion.content}
|
||||
{opinion.instrucoes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +140,67 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="p-4 border-t border-border space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Itens por página:</span>
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={(value) => {
|
||||
setItemsPerPage(Number(value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="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>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Mostrando {startIndex + 1}-{Math.min(startIndex + itemsPerPage, searchResults.length)} de {searchResults.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
onClick={() => setCurrentPage(page)}
|
||||
isActive={currentPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Plus, Search, Trash2, Eye } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { LegalOpinion } from "./AgentView";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface AgentSidebarProps {
|
||||
opinions: LegalOpinion[];
|
||||
onCreateNew: () => void;
|
||||
onSelectOpinion: (opinion: LegalOpinion) => void;
|
||||
onDeleteOpinion: (id: string) => void;
|
||||
onSearch: () => void;
|
||||
selectedOpinionId?: string;
|
||||
}
|
||||
|
||||
export const AgentSidebar = ({
|
||||
opinions,
|
||||
onCreateNew,
|
||||
onSelectOpinion,
|
||||
onDeleteOpinion,
|
||||
onSearch,
|
||||
selectedOpinionId,
|
||||
}: AgentSidebarProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredOpinions = opinions.filter(op =>
|
||||
op.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-80 border-r border-border bg-sidebar flex flex-col h-full">
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<h2 className="text-xl font-bold text-sidebar-foreground">Agente de Parecer</h2>
|
||||
<Button onClick={onCreateNew} className="w-full gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Novo Parecer
|
||||
</Button>
|
||||
<Button onClick={onSearch} variant="outline" className="w-full gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
Pesquisar Base de Pareceres
|
||||
</Button>
|
||||
<Input
|
||||
placeholder="Buscar pareceres..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Título</TableHead>
|
||||
<TableHead className="w-[120px]">Data</TableHead>
|
||||
<TableHead className="w-[100px] text-center">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOpinions.map((opinion) => (
|
||||
<TableRow
|
||||
key={opinion.id}
|
||||
className={selectedOpinionId === opinion.id ? "bg-sidebar-accent" : ""}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div>
|
||||
<p className="truncate">{opinion.title}</p>
|
||||
{opinion.category && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{opinion.category}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onSelectOpinion(opinion)}
|
||||
title="Visualizar"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onDeleteOpinion(opinion.id)}
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{filteredOpinions.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<p className="text-sm">Nenhum parecer encontrado</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +1,530 @@
|
||||
import { useState } from "react";
|
||||
import { AgentSidebar } from "./AgentSidebar";
|
||||
import { AgentChat } from "./AgentChat";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Plus, Search, Eye, Trash2, ArrowUpDown, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { OpinionDialog } from "./OpinionDialog";
|
||||
import { AgentSearch } from "./AgentSearch";
|
||||
import { agentService, OpinionRecord } from "@/services/agent";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export interface LegalOpinion {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
category?: string;
|
||||
}
|
||||
type SortField = "titulo" | "created_at" | "categoria";
|
||||
type SortOrder = "asc" | "desc";
|
||||
|
||||
export const AgentView = () => {
|
||||
const [opinions, setOpinions] = useState<LegalOpinion[]>([]);
|
||||
const [selectedOpinion, setSelectedOpinion] = useState<LegalOpinion | null>(null);
|
||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
||||
const [opinions, setOpinions] = useState<OpinionRecord[]>([]);
|
||||
const [pendingOpinions, setPendingOpinions] = useState<OpinionRecord[]>([]); // Registros temporários sendo processados
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [sortField, setSortField] = useState<SortField>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [selectedOpinion, setSelectedOpinion] = useState<OpinionRecord | null>(null);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [opinionToDelete, setOpinionToDelete] = useState<OpinionRecord | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setIsCreatingNew(true);
|
||||
setSelectedOpinion(null);
|
||||
setShowSearch(false);
|
||||
};
|
||||
// Carrega pareceres da API
|
||||
const loadOpinions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await agentService.getOpinions({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchTerm,
|
||||
});
|
||||
|
||||
const handleSelectOpinion = (opinion: LegalOpinion) => {
|
||||
setSelectedOpinion(opinion);
|
||||
setIsCreatingNew(false);
|
||||
setShowSearch(false);
|
||||
};
|
||||
// Adiciona status aos pareceres da API
|
||||
const opinionsWithStatus = data.map(opinion => ({
|
||||
...opinion,
|
||||
status: (opinion.file_url || opinion.file_url_melhoria) ? 'concluido' : 'processando' as const,
|
||||
}));
|
||||
|
||||
const handleSearch = () => {
|
||||
setShowSearch(true);
|
||||
setIsCreatingNew(false);
|
||||
setSelectedOpinion(null);
|
||||
};
|
||||
setOpinions(opinionsWithStatus);
|
||||
|
||||
const handleOpinionCreated = (newOpinion: LegalOpinion) => {
|
||||
setOpinions([newOpinion, ...opinions]);
|
||||
setSelectedOpinion(newOpinion);
|
||||
setIsCreatingNew(false);
|
||||
};
|
||||
// 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) {
|
||||
console.error('Erro ao carregar pareceres:', error);
|
||||
toast({
|
||||
title: "Erro ao carregar pareceres",
|
||||
description: error.message || "Não foi possível carregar a lista de pareceres.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchTerm, toast]);
|
||||
|
||||
const handleDeleteOpinion = (id: string) => {
|
||||
setOpinions(opinions.filter(op => op.id !== id));
|
||||
if (selectedOpinion?.id === id) {
|
||||
setSelectedOpinion(null);
|
||||
// Polling automático para atualizar status dos pareceres
|
||||
useEffect(() => {
|
||||
// Carrega imediatamente
|
||||
loadOpinions();
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (currentPage === 1) {
|
||||
loadOpinions();
|
||||
} else {
|
||||
setCurrentPage(1); // Volta para a primeira página ao buscar
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, loadOpinions]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// Mescla pareceres da API com registros temporários
|
||||
const allOpinions = [...pendingOpinions, ...opinions];
|
||||
|
||||
const sortedOpinions = [...allOpinions].sort((a, b) => {
|
||||
const multiplier = sortOrder === "asc" ? 1 : -1;
|
||||
|
||||
if (sortField === "created_at") {
|
||||
return multiplier * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
}
|
||||
|
||||
const aValue = a[sortField] || "";
|
||||
const bValue = b[sortField] || "";
|
||||
return multiplier * aValue.toString().localeCompare(bValue.toString());
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
loadOpinions();
|
||||
};
|
||||
|
||||
const handleViewOpinion = (opinion: OpinionRecord) => {
|
||||
setSelectedOpinion(opinion);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleNewOpinion = () => {
|
||||
setSelectedOpinion(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSelectFromSearch = (opinion: OpinionRecord) => {
|
||||
setSelectedOpinion(opinion);
|
||||
setShowSearch(false);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDownloadVersion = async (opinion: OpinionRecord, version: 'v1' | 'v2') => {
|
||||
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
|
||||
|
||||
if (!fileUrl) {
|
||||
toast({
|
||||
title: "Arquivo não disponível",
|
||||
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
|
||||
await agentService.downloadOpinion(fileUrl, fileName);
|
||||
|
||||
toast({
|
||||
title: "Download iniciado",
|
||||
description: "O parecer está sendo baixado.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Erro ao fazer download",
|
||||
description: error.message || "Não foi possível baixar o arquivo.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOpinion = async () => {
|
||||
if (!opinionToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await agentService.deleteOpinion(opinionToDelete.id);
|
||||
|
||||
toast({
|
||||
title: "Parecer excluído",
|
||||
description: "O parecer foi excluído com sucesso.",
|
||||
});
|
||||
|
||||
// Recarrega a lista de pareceres
|
||||
await loadOpinions();
|
||||
|
||||
// Fecha o diálogo de confirmação
|
||||
setOpinionToDelete(null);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Erro ao excluir parecer",
|
||||
description: error.message || "Não foi possível excluir o parecer.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showSearch) {
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<AgentSidebar
|
||||
opinions={opinions}
|
||||
onCreateNew={handleCreateNew}
|
||||
onSelectOpinion={handleSelectOpinion}
|
||||
onDeleteOpinion={handleDeleteOpinion}
|
||||
onSearch={handleSearch}
|
||||
selectedOpinionId={selectedOpinion?.id}
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-6 border-b border-border">
|
||||
<Button variant="outline" onClick={() => setShowSearch(false)}>
|
||||
Voltar para Pareceres
|
||||
</Button>
|
||||
</div>
|
||||
<AgentSearch onSelectOpinion={handleSelectFromSearch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">Gerador de Modelos de Parecer Jurídico</h1>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button onClick={() => setShowSearch(true)} variant="outline" className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
||||
<Search className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Pesquisar Base</span>
|
||||
<span className="sm:hidden">Base</span>
|
||||
</Button>
|
||||
<Button onClick={handleNewOpinion} 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 md:flex-row items-stretch md:items-center gap-2 md:gap-4">
|
||||
<Input
|
||||
placeholder="Buscar pareceres..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full md:max-w-sm text-sm"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
{showSearch ? (
|
||||
<AgentSearch onSelectOpinion={handleSelectOpinion} />
|
||||
<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={(value) => {
|
||||
setItemsPerPage(Number(value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[200px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("titulo")}
|
||||
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
|
||||
>
|
||||
Título
|
||||
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("categoria")}
|
||||
className="flex items-center gap-1 font-semibold text-sm"
|
||||
>
|
||||
Categoria
|
||||
<ArrowUpDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("created_at")}
|
||||
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
|
||||
>
|
||||
Data
|
||||
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
|
||||
Carregando pareceres...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : sortedOpinions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
|
||||
{searchTerm
|
||||
? "Nenhum parecer encontrado"
|
||||
: "Nenhum parecer criado ainda. Clique em 'Novo Parecer' para começar."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<AgentChat
|
||||
selectedOpinion={selectedOpinion}
|
||||
isCreatingNew={isCreatingNew}
|
||||
onOpinionCreated={handleOpinionCreated}
|
||||
/>
|
||||
sortedOpinions.map((opinion) => (
|
||||
<TableRow key={opinion.id}>
|
||||
<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 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">
|
||||
{new Date(opinion.created_at).toLocaleDateString("pt-BR")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewOpinion(opinion)}
|
||||
title="Visualizar"
|
||||
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" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Baixar"
|
||||
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" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadVersion(opinion, 'v1')}
|
||||
disabled={!opinion.file_url}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Versão 1
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDownloadVersion(opinion, 'v2')}
|
||||
disabled={!opinion.file_url_melhoria}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Versão Melhorada
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setOpinionToDelete(opinion)}
|
||||
title="Excluir"
|
||||
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" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{sortedOpinions.length > 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 {sortedOpinions.length} {sortedOpinions.length === 1 ? 'parecer' : 'pareceres'}
|
||||
</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;
|
||||
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>
|
||||
|
||||
<OpinionDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
selectedOpinion={selectedOpinion}
|
||||
onOpinionCreated={handleOpinionCreated}
|
||||
onOpinionCreating={handleOpinionCreating}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!opinionToDelete} onOpenChange={(open) => !open && setOpinionToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o parecer "{opinionToDelete?.titulo}"? Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteOpinion}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "Excluindo..." : "Excluir"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Download, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ToastAction } from "@/components/ui/toast";
|
||||
import { agentService, OpinionRecord } from "@/services/agent";
|
||||
|
||||
interface OpinionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedOpinion: OpinionRecord | null;
|
||||
onOpinionCreated: () => void;
|
||||
onOpinionCreating?: (tempOpinion: OpinionRecord) => void; // Callback para adicionar registro temporário
|
||||
}
|
||||
|
||||
export const OpinionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedOpinion,
|
||||
onOpinionCreated,
|
||||
onOpinionCreating,
|
||||
}: OpinionDialogProps) => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [createdOpinion, setCreatedOpinion] = useState<OpinionRecord | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOpinion) {
|
||||
setTitle(selectedOpinion.titulo);
|
||||
setCategory(selectedOpinion.categoria || "");
|
||||
setInstructions(selectedOpinion.instrucoes || "");
|
||||
setCreatedOpinion(selectedOpinion);
|
||||
} else {
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setInstructions("");
|
||||
setCreatedOpinion(null);
|
||||
}
|
||||
}, [selectedOpinion, open]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!instructions.trim()) {
|
||||
toast({
|
||||
title: "Instruções necessárias",
|
||||
description: "Por favor, forneça instruções para gerar o parecer.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title.trim()) {
|
||||
toast({
|
||||
title: "Título necessário",
|
||||
description: "Por favor, forneça um título para o parecer.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Criar registro temporário para adicionar na tabela imediatamente
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const tempOpinion: OpinionRecord = {
|
||||
id: tempId,
|
||||
estabelecimento_id: 0,
|
||||
user_email: '',
|
||||
titulo: title,
|
||||
categoria: category,
|
||||
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
|
||||
});
|
||||
|
||||
// Fecha o dialog imediatamente
|
||||
setIsGenerating(false);
|
||||
onOpenChange(false);
|
||||
|
||||
// Limpa os campos
|
||||
setTitle("");
|
||||
setCategory("");
|
||||
setInstructions("");
|
||||
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 {
|
||||
throw new Error('Falha ao criar parecer');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao gerar parecer:", error);
|
||||
|
||||
const errorMessage = error.message || "Não foi possível gerar o parecer. Tente novamente.";
|
||||
|
||||
// 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",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
duration: 10000, // Toast de erro fecha após 10 segundos
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadVersion = async (version: 'v1' | 'v2') => {
|
||||
if (!createdOpinion && !selectedOpinion) {
|
||||
toast({
|
||||
title: "Nenhum parecer disponível",
|
||||
description: "Por favor, gere um parecer primeiro.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const opinion = createdOpinion || selectedOpinion;
|
||||
if (!opinion) return;
|
||||
|
||||
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
|
||||
|
||||
if (!fileUrl) {
|
||||
toast({
|
||||
title: "Arquivo não disponível",
|
||||
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
|
||||
await agentService.downloadOpinion(fileUrl, fileName);
|
||||
|
||||
toast({
|
||||
title: "Download iniciado",
|
||||
description: "O parecer está sendo baixado.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Erro ao fazer download",
|
||||
description: error.message || "Não foi possível baixar o arquivo.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedOpinion ? "Visualizar Parecer" : "Novo Parecer Jurídico"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedOpinion
|
||||
? "Visualize os detalhes do parecer e faça o download das versões disponíveis"
|
||||
: "Preencha os dados e instruções para gerar um novo parecer com IA"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-120px)] pr-4">
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Título do Parecer *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
|
||||
disabled={!!selectedOpinion}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Categoria</Label>
|
||||
<Input
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="Ex: Direito Civil, Trabalhista, etc."
|
||||
disabled={!!selectedOpinion}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instructions">
|
||||
Instruções para gerar o parecer *
|
||||
</Label>
|
||||
<Textarea
|
||||
id="instructions"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
|
||||
className="min-h-[150px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!selectedOpinion && (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>Gerando parecer...</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Gerar Parecer com IA
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedOpinion && (
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<Label>Downloads Disponíveis</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={() => handleDownloadVersion('v1')}
|
||||
variant="outline"
|
||||
className="w-full gap-2 justify-start"
|
||||
disabled={!selectedOpinion.file_url}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Baixar Versão 1
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDownloadVersion('v2')}
|
||||
variant="outline"
|
||||
className="w-full gap-2 justify-start"
|
||||
disabled={!selectedOpinion.file_url_melhoria}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Baixar Versão Melhorada
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
Já 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">
|
||||
Já 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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
|
||||
@@ -186,19 +187,23 @@ export const AudioView = () => {
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleDownloadAudio = (audio: GeneratedAudio) => {
|
||||
// In real implementation, download the actual audio file
|
||||
const a = document.createElement('a');
|
||||
a.href = audio.audioUrl;
|
||||
a.download = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
const handleDownloadAudio = async (audio: GeneratedAudio) => {
|
||||
const filename = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
|
||||
try {
|
||||
await audioGenerationService.downloadAudioFile(audio.audioUrl, filename);
|
||||
toast({
|
||||
title: "Download iniciado",
|
||||
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) => {
|
||||
@@ -270,22 +275,22 @@ export const AudioView = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||
<ChatHeader />
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<Tabs defaultValue="transcription" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4 glass-effect">
|
||||
<TabsTrigger value="transcription">
|
||||
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
||||
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4 glass-effect text-xs md:text-sm">
|
||||
<TabsTrigger value="transcription" className="text-xs md:text-sm">
|
||||
Transcrição
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="generation">Geração de Voz</TabsTrigger>
|
||||
<TabsTrigger value="transcription-history">
|
||||
Histórico Transcrições
|
||||
<TabsTrigger value="generation" className="text-xs md:text-sm">Geração</TabsTrigger>
|
||||
<TabsTrigger value="transcription-history" className="text-xs md:text-sm">
|
||||
Hist. Trans.
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio-history">
|
||||
Histórico Áudios
|
||||
<TabsTrigger value="audio-history" className="text-xs md:text-sm">
|
||||
Hist. Áud.
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -482,10 +487,11 @@ export const AudioView = () => {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => handleDownloadAudio(generatedAudio)}
|
||||
onClick={() => void handleDownloadAudio(generatedAudio)}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
Baixar MP3
|
||||
@@ -658,9 +664,10 @@ export const AudioView = () => {
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadAudio(item)}
|
||||
onClick={() => void handleDownloadAudio(item)}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Mic, Download, Trash2, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { audioGenerationService, VOICE_OPTIONS, VoiceType, AudioRecord } from "@/services/audioGeneration";
|
||||
|
||||
export const GenerationView = () => {
|
||||
const [textToSpeech, setTextToSpeech] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
|
||||
const [lastGeneratedAudio, setLastGeneratedAudio] = useState<AudioRecord | null>(null);
|
||||
const [audioHistory, setAudioHistory] = useState<AudioRecord[]>([]);
|
||||
const [isLoadingAudios, setIsLoadingAudios] = useState(false);
|
||||
const [audioSearchQuery, setAudioSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Carrega áudios do banco de dados
|
||||
const loadAudios = async (page: number = currentPage, limit: number = perPage) => {
|
||||
setIsLoadingAudios(true);
|
||||
try {
|
||||
const fetchedAudios = await audioGenerationService.getAudios(undefined, page, limit);
|
||||
|
||||
// Garante que sempre seja um array
|
||||
if (Array.isArray(fetchedAudios)) {
|
||||
setAudioHistory(fetchedAudios);
|
||||
} else {
|
||||
console.warn('Resposta da API não é um array:', fetchedAudios);
|
||||
setAudioHistory([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao carregar áudios:', error);
|
||||
toast({
|
||||
title: "Erro ao carregar histórico",
|
||||
description: error.message || "Não foi possível carregar o histórico de áudios.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setAudioHistory([]);
|
||||
} finally {
|
||||
setIsLoadingAudios(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Carrega áudios ao montar e quando a paginação mudar
|
||||
useEffect(() => {
|
||||
loadAudios();
|
||||
}, [currentPage, perPage]);
|
||||
|
||||
const handleGenerateAudio = async () => {
|
||||
// Valida o texto antes de enviar
|
||||
const validation = audioGenerationService.validateText(textToSpeech);
|
||||
if (!validation.valid) {
|
||||
toast({
|
||||
title: "Texto inválido",
|
||||
description: validation.error,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// Chama o serviço de geração de áudio
|
||||
const response = await audioGenerationService.generateAudio({
|
||||
message: textToSpeech,
|
||||
voice: selectedVoice,
|
||||
});
|
||||
|
||||
// Verifica se a geração foi bem-sucedida
|
||||
if (response.success) {
|
||||
console.log('URL do áudio gerado:', response.audio_url);
|
||||
|
||||
// Cria objeto do áudio recém-gerado para exibição imediata
|
||||
const newGeneratedAudio: AudioRecord = {
|
||||
id: response.audio_generation_id,
|
||||
user_email: '',
|
||||
estabelecimento_id: 0,
|
||||
input_text: response.message,
|
||||
model: 'tts-1',
|
||||
voice: selectedVoice,
|
||||
audio_url: response.audio_url,
|
||||
duration_seconds: null,
|
||||
file_size: 0,
|
||||
cost_usd: '0',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Salva o último áudio gerado para exibição
|
||||
setLastGeneratedAudio(newGeneratedAudio);
|
||||
|
||||
toast({
|
||||
title: "Áudio gerado com sucesso",
|
||||
description: `Voz: ${VOICE_OPTIONS[selectedVoice].label}`,
|
||||
});
|
||||
|
||||
// Limpa o campo de texto após sucesso
|
||||
setTextToSpeech("");
|
||||
|
||||
// Recarrega a lista de áudios (sem aguardar para não bloquear a UI)
|
||||
loadAudios(1, perPage);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
throw new Error('Erro ao gerar áudio');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro na geração de áudio:', error);
|
||||
|
||||
toast({
|
||||
title: "Erro na geração",
|
||||
description: error.message || "Não foi possível gerar o áudio. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAudio = async (audio: AudioRecord) => {
|
||||
const filename = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
|
||||
try {
|
||||
await audioGenerationService.downloadAudioFile(audio.audio_url, filename);
|
||||
toast({
|
||||
title: "Download iniciado",
|
||||
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) => {
|
||||
try {
|
||||
const result = await audioGenerationService.deleteAudio(audioId);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Áudio removido",
|
||||
description: "O áudio foi removido com sucesso.",
|
||||
});
|
||||
|
||||
// Se o áudio deletado for o último gerado, limpa o preview
|
||||
if (lastGeneratedAudio && lastGeneratedAudio.id === audioId) {
|
||||
setLastGeneratedAudio(null);
|
||||
}
|
||||
|
||||
// Recarrega a lista de áudios
|
||||
await loadAudios();
|
||||
} else {
|
||||
throw new Error(result.message || 'Erro ao deletar áudio');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao deletar áudio:', error);
|
||||
toast({
|
||||
title: "Erro ao remover",
|
||||
description: error.message || "Não foi possível remover o áudio.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(parseInt(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const filteredAudios = Array.isArray(audioHistory)
|
||||
? audioHistory.filter(item =>
|
||||
(VOICE_OPTIONS[item.voice]?.label || item.voice).toLowerCase().includes(audioSearchQuery.toLowerCase()) ||
|
||||
item.input_text.toLowerCase().includes(audioSearchQuery.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||
<ChatHeader />
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
||||
<Tabs defaultValue="generation" className="space-y-4 md:space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
|
||||
<TabsTrigger value="generation" className="text-xs md:text-sm">
|
||||
Geração
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="text-xs md:text-sm">
|
||||
Histórico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="generation" className="space-y-6">
|
||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Converter Texto em Voz
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Digite o texto para converter
|
||||
</label>
|
||||
<Textarea
|
||||
value={textToSpeech}
|
||||
onChange={(e) => setTextToSpeech(e.target.value)}
|
||||
placeholder="Digite o texto que deseja converter em fala..."
|
||||
className="min-h-[120px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tipo de Voz</label>
|
||||
<Select value={selectedVoice} onValueChange={(value) => setSelectedVoice(value as VoiceType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(VOICE_OPTIONS).map(([key, voice]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{voice.label} - {voice.gender}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{VOICE_OPTIONS[selectedVoice].label}</p>
|
||||
<span className="text-xs text-muted-foreground">Modelo: OpenAI TTS-1</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Estilo:</strong> {VOICE_OPTIONS[selectedVoice].style}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{VOICE_OPTIONS[selectedVoice].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateAudio}
|
||||
disabled={!textToSpeech.trim() || isProcessing}
|
||||
className="gap-2 cyber-glow w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
{isProcessing ? "Gerando..." : "Gerar Áudio"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-sm font-medium">
|
||||
Gerando áudio...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastGeneratedAudio && (
|
||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold">Áudio Gerado</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Voz: {VOICE_OPTIONS[lastGeneratedAudio.voice]?.label || lastGeneratedAudio.voice}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => void handleDownloadAudio(lastGeneratedAudio)}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
Baixar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1"
|
||||
onClick={() => handleDeleteAudio(lastGeneratedAudio.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<p className="text-sm mb-3">{lastGeneratedAudio.input_text}</p>
|
||||
<audio
|
||||
key={lastGeneratedAudio.id}
|
||||
controls
|
||||
className="w-full"
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={lastGeneratedAudio.audio_url} type="audio/mpeg" />
|
||||
Seu navegador não suporta o elemento de áudio.
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={audioSearchQuery}
|
||||
onChange={(e) => setAudioSearchQuery(e.target.value)}
|
||||
placeholder="Buscar por voz ou conteúdo..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="glass-effect bg-popover border-border z-50">
|
||||
<SelectItem value="5">5 por página</SelectItem>
|
||||
<SelectItem value="10">10 por página</SelectItem>
|
||||
<SelectItem value="20">20 por página</SelectItem>
|
||||
<SelectItem value="50">50 por página</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoadingAudios ? (
|
||||
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground">Carregando áudios...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredAudios.length === 0 ? (
|
||||
<div className="glass-effect rounded-xl p-12 text-center">
|
||||
<Mic className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<p className="text-muted-foreground">
|
||||
{audioSearchQuery ? 'Nenhum áudio encontrado' : 'Nenhum áudio gerado ainda'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{filteredAudios.map((audio) => (
|
||||
<div key={audio.id} className="glass-effect rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">
|
||||
Voz: {VOICE_OPTIONS[audio.voice]?.label || audio.voice}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(audio.created_at).toLocaleDateString('pt-BR')} às{' '}
|
||||
{new Date(audio.created_at).toLocaleTimeString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleDownloadAudio(audio)}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteAudio(audio.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{audio.input_text}
|
||||
</p>
|
||||
<audio
|
||||
key={audio.id}
|
||||
controls
|
||||
className="w-full"
|
||||
preload="metadata"
|
||||
>
|
||||
<source src={audio.audio_url} type="audio/mpeg" />
|
||||
</audio>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!audioSearchQuery && audioHistory.length >= perPage && (
|
||||
<div className="flex items-center justify-center gap-4 mt-6">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Anterior</span>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<span className="text-sm text-muted-foreground px-4">
|
||||
Página {currentPage}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={audioHistory.length < perPage}
|
||||
className="gap-1"
|
||||
>
|
||||
<span className="hidden md:inline">Próxima</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,472 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Upload, Download, Trash2, FileAudio, Copy, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { transcriptionService, TranscriptionRecord } from "@/services/transcription";
|
||||
|
||||
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
|
||||
|
||||
export const TranscriptionView = () => {
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [lastTranscriptionResult, setLastTranscriptionResult] = useState<TranscriptionRecord | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [transcriptionHistory, setTranscriptionHistory] = useState<TranscriptionRecord[]>([]);
|
||||
const [isLoadingTranscriptions, setIsLoadingTranscriptions] = useState(false);
|
||||
const [transcriptionSearchQuery, setTranscriptionSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Carrega transcrições do banco de dados
|
||||
const loadTranscriptions = async (page: number = currentPage, limit: number = perPage) => {
|
||||
setIsLoadingTranscriptions(true);
|
||||
try {
|
||||
const fetchedTranscriptions = await transcriptionService.getTranscriptions(undefined, page, limit);
|
||||
|
||||
// Garante que sempre seja um array
|
||||
if (Array.isArray(fetchedTranscriptions)) {
|
||||
setTranscriptionHistory(fetchedTranscriptions);
|
||||
} else {
|
||||
console.warn('Resposta da API não é um array:', fetchedTranscriptions);
|
||||
setTranscriptionHistory([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao carregar transcrições:', error);
|
||||
toast({
|
||||
title: "Erro ao carregar histórico",
|
||||
description: error.message || "Não foi possível carregar o histórico de transcrições.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTranscriptionHistory([]);
|
||||
} finally {
|
||||
setIsLoadingTranscriptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Carrega transcrições ao montar e quando a paginação mudar
|
||||
useEffect(() => {
|
||||
loadTranscriptions();
|
||||
}, [currentPage, perPage]);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Valida o arquivo usando o serviço
|
||||
const validation = transcriptionService.validateAudioFile(file);
|
||||
if (!validation.valid) {
|
||||
toast({
|
||||
title: "Arquivo inválido",
|
||||
description: validation.error,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(file);
|
||||
handleTranscription(file);
|
||||
};
|
||||
|
||||
const handleTranscription = async (file: File) => {
|
||||
setIsTranscribing(true);
|
||||
|
||||
try {
|
||||
// Chama o serviço de transcrição
|
||||
const response = await transcriptionService.transcribeAudio({
|
||||
audioFile: file,
|
||||
});
|
||||
|
||||
// Verifica se a transcrição foi bem-sucedida
|
||||
if (response.success) {
|
||||
// Cria objeto da transcrição recém-gerada para exibição imediata
|
||||
const newTranscription: TranscriptionRecord = {
|
||||
id: response.transcription_id,
|
||||
user_email: '',
|
||||
estabelecimento_id: 0,
|
||||
audio_file_name: file.name,
|
||||
audio_duration_seconds: 0,
|
||||
transcription_text: response.message,
|
||||
model: 'whisper-1',
|
||||
audio_url: response.audio_url,
|
||||
cost_usd: '0',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Salva a última transcrição para exibição
|
||||
setLastTranscriptionResult(newTranscription);
|
||||
|
||||
toast({
|
||||
title: "Transcrição concluída",
|
||||
description: "Seu áudio foi transcrito com sucesso!",
|
||||
});
|
||||
|
||||
// Recarrega a lista de transcrições (sem aguardar para não bloquear a UI)
|
||||
loadTranscriptions(1, perPage);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
throw new Error(response.message || 'Erro ao transcrever áudio');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro na transcrição:', error);
|
||||
|
||||
toast({
|
||||
title: "Erro na transcrição",
|
||||
description: error.message || "Não foi possível transcrever o áudio. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTranscription = () => {
|
||||
setLastTranscriptionResult(null);
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
const handleDeleteTranscriptionFromHistory = async (transcriptionId: string) => {
|
||||
try {
|
||||
const result = await transcriptionService.deleteTranscription(transcriptionId);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Transcrição removida",
|
||||
description: "A transcrição foi removida com sucesso.",
|
||||
});
|
||||
|
||||
// Se a transcrição deletada for a última gerada, limpa o preview
|
||||
if (lastTranscriptionResult && lastTranscriptionResult.id === transcriptionId) {
|
||||
setLastTranscriptionResult(null);
|
||||
}
|
||||
|
||||
// Recarrega a lista de transcrições
|
||||
await loadTranscriptions();
|
||||
} else {
|
||||
throw new Error(result.message || 'Erro ao deletar transcrição');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao deletar transcrição:', error);
|
||||
toast({
|
||||
title: "Erro ao remover",
|
||||
description: error.message || "Não foi possível remover a transcrição.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTranscription = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast({
|
||||
title: "Texto copiado",
|
||||
description: "A transcrição foi copiada para a área de transferência",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadTranscription = (transcription: TranscriptionRecord) => {
|
||||
const blob = new Blob([transcription.transcription_text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `transcricao_${transcription.audio_file_name}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "Download iniciado",
|
||||
description: "O arquivo de transcrição está sendo baixado.",
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(parseInt(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const filteredTranscriptions = Array.isArray(transcriptionHistory)
|
||||
? transcriptionHistory.filter(item =>
|
||||
item.audio_file_name.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
|
||||
item.transcription_text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||
<ChatHeader />
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
||||
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
|
||||
<TabsTrigger value="transcription" className="text-xs md:text-sm">
|
||||
Transcrição
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="text-xs md:text-sm">
|
||||
Histórico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="transcription" className="space-y-6">
|
||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Converter Áudio em Texto
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Envie um arquivo de áudio para transcrição (máx. 25MB)
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Formatos: {SUPPORTED_FORMATS.join(', ')}
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="audio-upload"
|
||||
className="hidden"
|
||||
accept={SUPPORTED_FORMATS.map(f => `.${f}`).join(',')}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="audio-upload"
|
||||
className="border-2 border-dashed border-border rounded-xl p-12 text-center space-y-4 hover:border-primary/50 transition-colors cursor-pointer block"
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{selectedFile ? (
|
||||
<FileAudio className="w-8 h-8 text-primary" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{selectedFile ? selectedFile.name : "Clique para selecionar ou arraste o arquivo"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{selectedFile
|
||||
? `${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
|
||||
: "Máximo 25 MB"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
{!selectedFile && (
|
||||
<Button variant="outline" className="gap-2" type="button">
|
||||
<Upload className="w-4 h-4" />
|
||||
Selecionar Arquivo
|
||||
</Button>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{isTranscribing && (
|
||||
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-sm font-medium">
|
||||
Transcrevendo áudio...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastTranscriptionResult && (
|
||||
<div className="glass-effect rounded-xl p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold">{lastTranscriptionResult.audio_file_name}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transcrito {new Date(lastTranscriptionResult.created_at).toLocaleTimeString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => handleCopyTranscription(lastTranscriptionResult.transcription_text)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
Copiar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => handleDownloadTranscription(lastTranscriptionResult)}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
Baixar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="gap-1"
|
||||
onClick={handleDeleteTranscription}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{lastTranscriptionResult.transcription_text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={transcriptionSearchQuery}
|
||||
onChange={(e) => setTranscriptionSearchQuery(e.target.value)}
|
||||
placeholder="Buscar por nome ou conteúdo..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="glass-effect bg-popover border-border z-50">
|
||||
<SelectItem value="5">5 por página</SelectItem>
|
||||
<SelectItem value="10">10 por página</SelectItem>
|
||||
<SelectItem value="20">20 por página</SelectItem>
|
||||
<SelectItem value="50">50 por página</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoadingTranscriptions ? (
|
||||
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground">Carregando transcrições...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTranscriptions.length === 0 ? (
|
||||
<div className="glass-effect rounded-xl p-12 text-center">
|
||||
<FileAudio className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<p className="text-muted-foreground">
|
||||
{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{filteredTranscriptions.map((item) => (
|
||||
<div key={item.id} className="glass-effect rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium truncate">{item.audio_file_name}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(item.created_at).toLocaleDateString('pt-BR')} às{' '}
|
||||
{new Date(item.created_at).toLocaleTimeString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleCopyTranscription(item.transcription_text)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDownloadTranscription(item)}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteTranscriptionFromHistory(item.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{item.transcription_text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!transcriptionSearchQuery && transcriptionHistory.length >= perPage && (
|
||||
<div className="flex items-center justify-center gap-4 mt-6">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Anterior</span>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<span className="text-sm text-muted-foreground px-4">
|
||||
Página {currentPage}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={transcriptionHistory.length < perPage}
|
||||
className="gap-1"
|
||||
>
|
||||
<span className="hidden md:inline">Próxima</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -95,19 +95,19 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="border-b border-border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bot className="w-6 h-6" />
|
||||
<h2 className="text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||
<Bot className="w-5 h-5 md:w-6 md:h-6" />
|
||||
Gerenciador de Bots
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p>
|
||||
<p className="text-sm md:text-base text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p>
|
||||
</div>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button className="w-full md:w-auto text-sm">
|
||||
<Bot className="w-4 h-4 mr-2" />
|
||||
Criar Bot
|
||||
</Button>
|
||||
@@ -167,7 +167,7 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{bots.length === 0 ? (
|
||||
<Card className="max-w-2xl mx-auto mt-12">
|
||||
<CardHeader>
|
||||
@@ -190,45 +190,48 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="max-w-6xl mx-auto overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Modelo</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
<TableHead className="text-xs md:text-sm">Nome</TableHead>
|
||||
<TableHead className="hidden md:table-cell text-sm">Modelo</TableHead>
|
||||
<TableHead className="text-right text-xs md:text-sm">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentBots.map((bot) => (
|
||||
<TableRow key={bot.id}>
|
||||
<TableCell className="font-medium">{bot.name}</TableCell>
|
||||
<TableCell>{bot.model}</TableCell>
|
||||
<TableCell className="font-medium text-xs md:text-sm">{bot.name}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-sm">{bot.model}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div className="flex gap-1 justify-end flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onStartChat(bot)}
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Conversar
|
||||
<MessageSquare className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">Conversar</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openEditDialog(bot)}
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
<Edit className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">Editar</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openDeleteDialog(bot)}
|
||||
className="text-xs md:text-sm"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Excluir
|
||||
<Trash2 className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||
<span className="hidden md:inline">Excluir</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { chatService } from "@/services/chat";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (message: string, files?: File[]) => void;
|
||||
@@ -67,21 +68,54 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const newFiles = [...attachedFiles, ...files];
|
||||
|
||||
// Validate file size (max 10MB per file)
|
||||
const validFiles = files.filter((file) => {
|
||||
// Valida quantidade máxima de anexos (5)
|
||||
if (newFiles.length > chatService.getMaxAttachments()) {
|
||||
toast({
|
||||
title: "Limite de anexos excedido",
|
||||
description: `Você pode anexar no máximo ${chatService.getMaxAttachments()} arquivos por mensagem.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Valida tipos de arquivo e tamanho
|
||||
const validFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
// Valida tamanho (max 10MB por arquivo)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: "Arquivo muito grande",
|
||||
description: `${file.name} excede o limite de 10MB`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
setAttachedFiles([...attachedFiles, ...validFiles]);
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// Valida formatos permitidos
|
||||
const allFiles = [...attachedFiles, ...validFiles];
|
||||
const validation = chatService.validateAttachments(allFiles);
|
||||
|
||||
if (!validation.valid) {
|
||||
toast({
|
||||
title: "Formato de arquivo não permitido",
|
||||
description: validation.error,
|
||||
variant: "destructive",
|
||||
});
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachedFiles(allFiles);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
@@ -92,19 +126,19 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-4">
|
||||
<div className="max-w-4xl mx-auto space-y-3">
|
||||
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
|
||||
<div className="max-w-4xl mx-auto space-y-2 md:space-y-3">
|
||||
{/* Attached Files Preview */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1 md:gap-2">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-muted border border-border text-sm"
|
||||
className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1 md:py-1.5 rounded-lg bg-muted border border-border text-xs md:text-sm"
|
||||
>
|
||||
<Paperclip className="w-3 h-3 text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span>
|
||||
<span className="hidden md:inline text-xs text-muted-foreground">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
<Button
|
||||
@@ -121,12 +155,12 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
)}
|
||||
|
||||
{/* Message Input and Buttons */}
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex gap-1 md:gap-2 items-end">
|
||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
||||
{/* Resize Handle */}
|
||||
{/* Resize Handle - Hidden on mobile */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={`w-full h-6 flex items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
|
||||
className={`hidden md:flex w-full h-6 items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
|
||||
isDragging ? 'bg-primary/20' : ''
|
||||
}`}
|
||||
title="Arraste para cima para aumentar"
|
||||
@@ -135,51 +169,52 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
style={{ height: `${textareaHeight}px` }}
|
||||
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto"
|
||||
style={{ height: window.innerWidth < 768 ? '80px' : `${textareaHeight}px` }}
|
||||
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto text-sm md:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1 md:gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept=".pdf,.txt,.doc,.docx,.xls,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp"
|
||||
accept={chatService.getAllowedFileExtensions()}
|
||||
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11 glass-effect"
|
||||
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Anexar arquivo"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className="h-11 w-11 cyber-glow"
|
||||
className="h-9 w-9 md:h-11 md:w-11 cyber-glow"
|
||||
disabled={!message.trim() && attachedFiles.length === 0}
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
<Send className="w-4 h-4 md:w-5 md:h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. 10MB por arquivo
|
||||
<p className="hidden md:block text-xs text-muted-foreground text-center">
|
||||
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()} anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Bot, User, Copy, File } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useState } from "react";
|
||||
@@ -30,22 +31,22 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-4 py-6 px-6 group hover:bg-muted/10 transition-colors ${
|
||||
className={`flex gap-2 md:gap-4 py-4 md:py-6 px-3 md:px-6 group hover:bg-muted/10 transition-colors ${
|
||||
isAssistant ? "justify-start" : "justify-end"
|
||||
}`}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
{isAssistant && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary">
|
||||
<Bot className="w-5 h-5 text-primary-foreground" />
|
||||
<div className="flex flex-col items-center gap-1 md:gap-2">
|
||||
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary">
|
||||
<Bot className="w-4 h-4 md:w-5 md:h-5 text-primary-foreground" />
|
||||
</div>
|
||||
|
||||
{model && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30"
|
||||
className="hidden md:block text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30"
|
||||
>
|
||||
{model}
|
||||
</Badge>
|
||||
@@ -53,7 +54,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex-1 max-w-3xl 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.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
@@ -73,15 +74,19 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`inline-block px-4 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
|
||||
? "bg-card border border-border"
|
||||
? "bg-card border border-border text-foreground"
|
||||
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
<p className={`leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}>
|
||||
{content}
|
||||
</p>
|
||||
{isAssistant ? (
|
||||
<div className="prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-p:leading-relaxed">
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm md:text-base leading-relaxed text-white">{content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
@@ -100,8 +105,8 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
||||
</div>
|
||||
|
||||
{!isAssistant && (
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent">
|
||||
<User className="w-5 h-5 text-accent" />
|
||||
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent">
|
||||
<User className="w-4 h-4 md:w-5 md:h-5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+298
-174
@@ -1,8 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { chatService, ChatRecord, FolderRecord } from "@/services/chat";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -30,115 +34,159 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: string;
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
interface ChatFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ChatSidebarProps {
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onNewChat: () => void;
|
||||
onSelectChat?: (chat: ChatRecord) => void;
|
||||
currentChatId?: string;
|
||||
}
|
||||
|
||||
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSidebarProps) => {
|
||||
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: ChatSidebarProps) => {
|
||||
const { toast } = useToast();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
|
||||
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
|
||||
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
|
||||
const [isRenameFolderOpen, setIsRenameFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [editingFolder, setEditingFolder] = useState<ChatFolder | null>(null);
|
||||
const [deletingFolder, setDeletingFolder] = useState<ChatFolder | null>(null);
|
||||
const [selectedConversation, setSelectedConversation] = useState<string | null>("1");
|
||||
const [renamingFolder, setRenamingFolder] = useState<FolderRecord | null>(null);
|
||||
const [renamedFolderName, setRenamedFolderName] = useState("");
|
||||
const [deletingFolder, setDeletingFolder] = useState<FolderRecord | null>(null);
|
||||
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [folders, setFolders] = useState<ChatFolder[]>([
|
||||
{ id: "work", name: "Trabalho" },
|
||||
{ id: "personal", name: "Pessoal" },
|
||||
]);
|
||||
// Estado carregado do banco de dados via chatService
|
||||
const [chats, setChats] = useState<ChatRecord[]>([]);
|
||||
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
const [conversations, setConversations] = useState<Conversation[]>([
|
||||
{
|
||||
id: "1",
|
||||
title: "Conversa sobre IA",
|
||||
lastMessage: "Como funciona o machine learning?",
|
||||
timestamp: "Há 5 min",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Projeto HGTX",
|
||||
lastMessage: "Discutindo arquitetura do sistema",
|
||||
timestamp: "Há 1 hora",
|
||||
folderId: "work",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Ideias criativas",
|
||||
lastMessage: "Brainstorm para novo produto",
|
||||
timestamp: "Ontem",
|
||||
folderId: "personal",
|
||||
},
|
||||
]);
|
||||
// Carrega chats e pastas do banco de dados quando o componente monta
|
||||
useEffect(() => {
|
||||
loadChatsAndFolders();
|
||||
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["work", "personal"]));
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
if (newFolderName.trim()) {
|
||||
const newFolder: ChatFolder = {
|
||||
id: Date.now().toString(),
|
||||
name: newFolderName,
|
||||
// Escuta evento customizado para recarregar quando chat é atualizado
|
||||
const handleChatUpdate = () => {
|
||||
loadChatsAndFolders();
|
||||
};
|
||||
setFolders([...folders, newFolder]);
|
||||
|
||||
window.addEventListener('chatUpdated', handleChatUpdate);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('chatUpdated', handleChatUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadChatsAndFolders = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await chatService.getChatsAndFolders();
|
||||
|
||||
console.log('Dados carregados:', data);
|
||||
|
||||
setChats(data.chats);
|
||||
setFolders(data.folders);
|
||||
|
||||
// Expande todas as pastas por padrão
|
||||
setExpandedFolders(new Set(data.folders.map(f => f.id)));
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao carregar chats e pastas:', error);
|
||||
toast({
|
||||
title: "Erro ao carregar dados",
|
||||
description: error.message || "Não foi possível carregar chats e pastas.",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// Define arrays vazios em caso de erro
|
||||
setChats([]);
|
||||
setFolders([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (newFolderName.trim()) {
|
||||
try {
|
||||
await chatService.createFolder(newFolderName);
|
||||
await loadChatsAndFolders();
|
||||
setNewFolderName("");
|
||||
setIsCreateFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta criada",
|
||||
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao criar pasta:', error);
|
||||
toast({
|
||||
title: "Erro ao criar pasta",
|
||||
description: error.message || "Não foi possível criar a pasta. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditFolder = () => {
|
||||
if (editingFolder && newFolderName.trim()) {
|
||||
setFolders(
|
||||
folders.map((f) =>
|
||||
f.id === editingFolder.id ? { ...f, name: newFolderName } : f
|
||||
)
|
||||
);
|
||||
setNewFolderName("");
|
||||
setEditingFolder(null);
|
||||
setIsEditFolderOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFolder = () => {
|
||||
const handleDeleteFolder = async () => {
|
||||
if (deletingFolder) {
|
||||
// Remove folder and move conversations back to "no folder"
|
||||
setConversations(
|
||||
conversations.map((conv) =>
|
||||
conv.folderId === deletingFolder.id ? { ...conv, folderId: undefined } : conv
|
||||
)
|
||||
);
|
||||
setFolders(folders.filter((f) => f.id !== deletingFolder.id));
|
||||
try {
|
||||
await chatService.deleteFolder(deletingFolder.id);
|
||||
await loadChatsAndFolders();
|
||||
setDeletingFolder(null);
|
||||
setIsDeleteFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta excluída",
|
||||
description: "A pasta foi excluída com sucesso.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao excluir pasta:', error);
|
||||
toast({
|
||||
title: "Erro ao excluir pasta",
|
||||
description: error.message || "Não foi possível excluir a pasta. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openEditFolder = (folder: ChatFolder) => {
|
||||
setEditingFolder(folder);
|
||||
setNewFolderName(folder.name);
|
||||
setIsEditFolderOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteFolder = (folder: ChatFolder) => {
|
||||
const openDeleteFolder = (folder: FolderRecord) => {
|
||||
setDeletingFolder(folder);
|
||||
setIsDeleteFolderOpen(true);
|
||||
};
|
||||
|
||||
const openRenameFolder = (folder: FolderRecord) => {
|
||||
setRenamingFolder(folder);
|
||||
setRenamedFolderName(folder.name);
|
||||
setIsRenameFolderOpen(true);
|
||||
};
|
||||
|
||||
const handleRenameFolder = async () => {
|
||||
if (renamingFolder && renamedFolderName.trim()) {
|
||||
try {
|
||||
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
||||
await loadChatsAndFolders();
|
||||
setRenamingFolder(null);
|
||||
setRenamedFolderName("");
|
||||
setIsRenameFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta renomeada",
|
||||
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao renomear pasta:', error);
|
||||
toast({
|
||||
title: "Erro ao renomear pasta",
|
||||
description: error.message || "Não foi possível renomear a pasta. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(folderId)) {
|
||||
@@ -149,37 +197,79 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
const moveToFolder = (conversationId: string, folderId: string) => {
|
||||
setConversations(
|
||||
conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, folderId } : conv
|
||||
)
|
||||
);
|
||||
const moveToFolder = async (chatId: string, folderId: string) => {
|
||||
try {
|
||||
await chatService.moveChatToFolder(chatId, folderId);
|
||||
await loadChatsAndFolders();
|
||||
|
||||
const folder = folders.find(f => f.id === folderId);
|
||||
toast({
|
||||
title: "Chat movido",
|
||||
description: `Movido para a pasta "${folder?.name}".`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao mover chat:', error);
|
||||
toast({
|
||||
title: "Erro ao mover chat",
|
||||
description: error.message || "Não foi possível mover o chat. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeFromFolder = (conversationId: string) => {
|
||||
setConversations(
|
||||
conversations.map((conv) =>
|
||||
conv.id === conversationId ? { ...conv, folderId: undefined } : conv
|
||||
)
|
||||
);
|
||||
const handleDeleteChat = async () => {
|
||||
if (deletingChat) {
|
||||
try {
|
||||
await chatService.deleteChat(deletingChat.id);
|
||||
await loadChatsAndFolders();
|
||||
setDeletingChat(null);
|
||||
setIsDeleteChatOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Chat excluído",
|
||||
description: "A conversa foi excluída com sucesso.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao excluir chat:', error);
|
||||
toast({
|
||||
title: "Erro ao excluir chat",
|
||||
description: error.message || "Não foi possível excluir o chat. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConversations = conversations.filter(
|
||||
(conv) =>
|
||||
conv.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
conv.lastMessage.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const openDeleteChat = (chat: ChatRecord) => {
|
||||
setDeletingChat(chat);
|
||||
setIsDeleteChatOpen(true);
|
||||
};
|
||||
|
||||
const conversationsWithoutFolder = filteredConversations.filter((c) => !c.folderId);
|
||||
const conversationsByFolder = folders.reduce((acc, folder) => {
|
||||
acc[folder.id] = filteredConversations.filter((c) => c.folderId === folder.id);
|
||||
const handleSelectChat = (chat: ChatRecord) => {
|
||||
if (onSelectChat) {
|
||||
onSelectChat(chat);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrar chats pela busca
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
||||
return titleMatch;
|
||||
});
|
||||
|
||||
// Separar chats sem pasta (folder_id é null)
|
||||
const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null);
|
||||
|
||||
// Agrupar chats por pasta (quando folder_id === folder.id)
|
||||
const chatsByFolder = folders.reduce((acc, folder) => {
|
||||
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
||||
return acc;
|
||||
}, {} as Record<string, Conversation[]>);
|
||||
}, {} as Record<string, ChatRecord[]>);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="w-12 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
|
||||
<div className="w-12 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -193,7 +283,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-80 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
|
||||
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
|
||||
{/* Header with collapse button */}
|
||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
|
||||
@@ -261,33 +351,34 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Folder Dialog */}
|
||||
<Dialog open={isEditFolderOpen} onOpenChange={setIsEditFolderOpen}>
|
||||
{/* Rename Folder Dialog */}
|
||||
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar Pasta</DialogTitle>
|
||||
<DialogTitle>Renomear Pasta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Renomeie sua pasta de conversas.
|
||||
Digite o novo nome para a pasta "{renamingFolder?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-folder-name">Nome da Pasta</Label>
|
||||
<Label htmlFor="rename-folder-name">Novo Nome</Label>
|
||||
<Input
|
||||
id="edit-folder-name"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Digite o novo nome..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleEditFolder()}
|
||||
id="rename-folder-name"
|
||||
value={renamedFolderName}
|
||||
onChange={(e) => setRenamedFolderName(e.target.value)}
|
||||
placeholder="Ex: Projetos, Estudos..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditFolderOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleEditFolder} disabled={!newFolderName.trim()}>
|
||||
Salvar
|
||||
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
||||
Renomear
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -299,7 +390,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. As conversas dentro da pasta serão movidas para "Sem Pasta".
|
||||
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -315,6 +406,12 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
{/* Conversations List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Folders */}
|
||||
{folders.map((folder) => (
|
||||
<div key={folder.id} className="space-y-1">
|
||||
@@ -330,7 +427,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{conversationsByFolder[folder.id]?.length || 0}
|
||||
{chatsByFolder[folder.id]?.length || 0}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -347,10 +444,10 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onClick={() => openEditFolder(folder)}
|
||||
onClick={() => openRenameFolder(folder)}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
Editar Nome
|
||||
Renomear Pasta
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive"
|
||||
@@ -363,17 +460,17 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{expandedFolders.has(folder.id) && conversationsByFolder[folder.id]?.length > 0 && (
|
||||
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
||||
<div className="ml-6 space-y-1">
|
||||
{conversationsByFolder[folder.id].map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isSelected={selectedConversation === conv.id}
|
||||
onSelect={() => setSelectedConversation(conv.id)}
|
||||
{chatsByFolder[folder.id].map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isSelected={currentChatId === chat.id}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
folders={folders}
|
||||
onMoveToFolder={moveToFolder}
|
||||
onRemoveFromFolder={removeFromFolder}
|
||||
onDelete={() => openDeleteChat(chat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -381,65 +478,103 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Conversations without folder */}
|
||||
{conversationsWithoutFolder.length > 0 && (
|
||||
{/* Chats without folder */}
|
||||
{chatsWithoutFolder.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Sem Pasta
|
||||
</div>
|
||||
{conversationsWithoutFolder.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isSelected={selectedConversation === conv.id}
|
||||
onSelect={() => setSelectedConversation(conv.id)}
|
||||
{chatsWithoutFolder.map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isSelected={currentChatId === chat.id}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
folders={folders}
|
||||
onMoveToFolder={moveToFolder}
|
||||
onRemoveFromFolder={removeFromFolder}
|
||||
onDelete={() => openDeleteChat(chat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && chats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conversa ainda
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Clique em "Novo Chat" para começar
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Delete Chat Alert */}
|
||||
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
interface ChatItemProps {
|
||||
chat: ChatRecord;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
folders: ChatFolder[];
|
||||
onMoveToFolder: (conversationId: string, folderId: string) => void;
|
||||
onRemoveFromFolder: (conversationId: string) => void;
|
||||
folders: FolderRecord[];
|
||||
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const ConversationItem = ({
|
||||
conversation,
|
||||
const ChatItem = ({
|
||||
chat,
|
||||
isSelected,
|
||||
onSelect,
|
||||
folders,
|
||||
onMoveToFolder,
|
||||
onRemoveFromFolder,
|
||||
}: ConversationItemProps) => {
|
||||
onDelete,
|
||||
}: ChatItemProps) => {
|
||||
// Formata timestamp relativo
|
||||
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${
|
||||
isSelected
|
||||
? "bg-sidebar-accent cyber-border"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{conversation.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{conversation.lastMessage}
|
||||
<MessageSquare className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 max-w-[180px]">
|
||||
<p className="text-sm font-medium truncate" title={chat.title}>
|
||||
{chat.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{conversation.timestamp}
|
||||
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
|
||||
{timeAgo}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -448,39 +583,28 @@ const ConversationItem = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<Edit className="w-3 h-3" />
|
||||
Renomear
|
||||
</DropdownMenuItem>
|
||||
|
||||
{conversation.folderId && (
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onClick={() => onRemoveFromFolder(conversation.id)}
|
||||
>
|
||||
<FolderInput className="w-3 h-3" />
|
||||
Remover da Pasta
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{folders.map((folder) => (
|
||||
{/* Opções para mover para pastas */}
|
||||
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
className="gap-2"
|
||||
onClick={() => onMoveToFolder(conversation.id, folder.id)}
|
||||
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||
>
|
||||
<Folder className="w-3 h-3" />
|
||||
Mover para {folder.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuItem className="gap-2 text-destructive">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getModelId } from "@/config/models";
|
||||
|
||||
// Texto padrão da personalidade (usado para novos chats)
|
||||
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -18,24 +24,64 @@ interface Message {
|
||||
}
|
||||
|
||||
export const ChatView = () => {
|
||||
const { toast } = useToast();
|
||||
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState("ChatGPT 4.1");
|
||||
const [currentChatId, setCurrentChatId] = useState("1");
|
||||
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
|
||||
const [selectedModel, setSelectedModel] = useState("GPT-4o");
|
||||
// Inicia com "0" - será atualizado com o chat_id real após primeira resposta da API
|
||||
const [currentChatId, setCurrentChatId] = useState("0");
|
||||
// Inicia com texto padrão - será atualizado ao carregar chat existente
|
||||
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: "ChatGPT 4.1",
|
||||
model: "GPT-4o",
|
||||
},
|
||||
]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
|
||||
// quando enviadas via handleSendMessage
|
||||
// useEffect(() => {
|
||||
// if (messages.length > 1) {
|
||||
// saveCurrentChat();
|
||||
// window.dispatchEvent(new Event('chatUpdated'));
|
||||
// }
|
||||
// }, [messages]);
|
||||
|
||||
// NOTA: Função de salvamento localStorage desabilitada - migrado para banco de dados
|
||||
// const saveCurrentChat = () => {
|
||||
// try {
|
||||
// const chatTitle = chatService.generateChatTitle(
|
||||
// messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
|
||||
// );
|
||||
// const storedChat: StoredChat = {
|
||||
// id: currentChatId,
|
||||
// title: chatTitle,
|
||||
// createdAt: new Date(),
|
||||
// updatedAt: new Date(),
|
||||
// model: selectedModel,
|
||||
// systemPrompt: systemPrompt,
|
||||
// messages: messages.map(msg => ({
|
||||
// ...msg,
|
||||
// timestamp: new Date(),
|
||||
// })),
|
||||
// };
|
||||
// chatService.saveChat(storedChat);
|
||||
// } catch (error) {
|
||||
// console.error('Erro ao salvar chat:', error);
|
||||
// }
|
||||
// };
|
||||
|
||||
const handleNewChat = () => {
|
||||
const newChatId = Date.now().toString();
|
||||
setCurrentChatId(newChatId);
|
||||
// Reseta para "0" - novo chat sempre começa com chat_id "0"
|
||||
setCurrentChatId("0");
|
||||
|
||||
// Reseta a personalidade para o texto padrão
|
||||
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
||||
|
||||
setMessages([
|
||||
{
|
||||
id: "1",
|
||||
@@ -44,9 +90,67 @@ export const ChatView = () => {
|
||||
model: selectedModel,
|
||||
},
|
||||
]);
|
||||
|
||||
// Dispara evento para atualizar a sidebar
|
||||
window.dispatchEvent(new Event('chatUpdated'));
|
||||
};
|
||||
|
||||
const handleSendMessage = (content: string, files?: File[]) => {
|
||||
const handleLoadChat = async (chat: ChatRecord) => {
|
||||
// Carrega um chat existente do banco de dados
|
||||
setCurrentChatId(chat.id);
|
||||
|
||||
// Atualiza a personalidade com o valor do banco de dados
|
||||
// Se não tiver personalidade salva, usa o texto padrão
|
||||
setSystemPrompt(chat.personalidade || DEFAULT_SYSTEM_PROMPT);
|
||||
|
||||
// Limpa mensagens enquanto carrega
|
||||
setMessages([]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Busca mensagens do chat na API
|
||||
const messagesFromAPI = await chatService.getChatMessages(chat.id);
|
||||
|
||||
// Converte MessageRecord para Message
|
||||
const loadedMessages: Message[] = messagesFromAPI.map((msg: MessageRecord) => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
model: msg.model_name, // Nome do modelo retornado pela API
|
||||
attachments: msg.has_attachments ? [] : undefined, // Não temos detalhes dos anexos no GET
|
||||
}));
|
||||
|
||||
setMessages(loadedMessages);
|
||||
|
||||
toast({
|
||||
title: "Chat carregado",
|
||||
description: `${loadedMessages.length} mensagens carregadas.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao carregar mensagens:', error);
|
||||
|
||||
toast({
|
||||
title: "Erro ao carregar chat",
|
||||
description: error.message || "Não foi possível carregar as mensagens.",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// Inicia com mensagem padrão em caso de erro
|
||||
setMessages([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: selectedModel,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async (content: string, files?: File[]) => {
|
||||
// Adiciona mensagem do usuário na interface
|
||||
const newMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: "user",
|
||||
@@ -61,31 +165,87 @@ export const ChatView = () => {
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Envia mensagem para a API
|
||||
// Converte o nome do modelo para o ID da API
|
||||
const modelId = getModelId(selectedModel);
|
||||
|
||||
const response = await chatService.sendMessage({
|
||||
chatId: currentChatId,
|
||||
message: content,
|
||||
modelId: modelId,
|
||||
personalidade: systemPrompt,
|
||||
anexos: files,
|
||||
});
|
||||
|
||||
// Adiciona resposta da IA na interface
|
||||
if (response.success) {
|
||||
// IMPORTANTE: Atualiza o chat_id com o valor retornado pela API
|
||||
// Isso mantém o contexto da conversa para as próximas mensagens
|
||||
if (response.chat_id && response.chat_id !== currentChatId) {
|
||||
console.log(`Chat ID atualizado: ${currentChatId} → ${response.chat_id}`);
|
||||
|
||||
// Se estava com chat_id "0", significa que é a primeira mensagem
|
||||
// e o chat acabou de ser criado no backend
|
||||
const isFirstMessage = currentChatId === "0";
|
||||
|
||||
setCurrentChatId(response.chat_id);
|
||||
|
||||
// Dispara evento para ChatSidebar recarregar e mostrar o novo chat
|
||||
if (isFirstMessage) {
|
||||
console.log('Primeira mensagem - novo chat criado, atualizando sidebar');
|
||||
window.dispatchEvent(new Event('chatUpdated'));
|
||||
}
|
||||
}
|
||||
|
||||
const aiResponse: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content:
|
||||
`Resposta com base na personalidade: "${systemPrompt}". Esta é uma resposta simulada que considera as instruções de sistema definidas.`,
|
||||
content: response.response,
|
||||
model: selectedModel,
|
||||
};
|
||||
setMessages((prev) => [...prev, aiResponse]);
|
||||
} else {
|
||||
throw new Error('Resposta inválida da API');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao enviar mensagem:', error);
|
||||
|
||||
// Mostra toast de erro para o usuário
|
||||
toast({
|
||||
title: "Erro ao enviar mensagem",
|
||||
description: error.message || "Não foi possível comunicar com a IA. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// Adiciona mensagem de erro na interface
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content: "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente.",
|
||||
model: selectedModel,
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Chat Sidebar */}
|
||||
<div className="flex h-full w-full pb-16 md:pb-0">
|
||||
{/* Chat Sidebar - Hidden on mobile */}
|
||||
<div className="hidden md:block">
|
||||
<ChatSidebar
|
||||
isCollapsed={isChatSidebarCollapsed}
|
||||
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
||||
onNewChat={handleNewChat}
|
||||
onSelectChat={handleLoadChat}
|
||||
currentChatId={currentChatId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 flex flex-col w-full">
|
||||
<ChatHeader
|
||||
showModelSelector={true}
|
||||
selectedModel={selectedModel}
|
||||
@@ -95,7 +255,7 @@ export const ChatView = () => {
|
||||
/>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Download, Trash2, Sparkles, Clock, Search } from "lucide-react";
|
||||
import { Download, Trash2, Sparkles, Clock, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -13,82 +13,226 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface GeneratedImage {
|
||||
id: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
timestamp: string;
|
||||
}
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
imageGenerationService,
|
||||
IMAGE_SIZE_OPTIONS,
|
||||
ImageSize,
|
||||
ImageRecord
|
||||
} from "@/services/imageGeneration";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
} from "@/components/ui/pagination";
|
||||
|
||||
export const ImageView = () => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [selectedSize, setSelectedSize] = useState("1024x1024");
|
||||
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [images, setImages] = useState<GeneratedImage[]>([
|
||||
{
|
||||
id: "1",
|
||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
|
||||
prompt: "Paisagem futurista com cidades voadoras",
|
||||
size: "1024x1024",
|
||||
timestamp: "Há 2 horas",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
url: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=512&h=512&fit=crop",
|
||||
prompt: "Robô humanoide em estilo cyberpunk",
|
||||
size: "1024x1536",
|
||||
timestamp: "Há 5 horas",
|
||||
},
|
||||
]);
|
||||
const [images, setImages] = useState<ImageRecord[]>([]);
|
||||
const [lastGeneratedImage, setLastGeneratedImage] = useState<ImageRecord | null>(null);
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Carrega imagens do banco de dados ao montar o componente
|
||||
const loadImages = async (page: number = currentPage, limit: number = perPage) => {
|
||||
setIsLoadingImages(true);
|
||||
try {
|
||||
const fetchedImages = await imageGenerationService.getImages(undefined, page, limit);
|
||||
|
||||
// Garante que sempre seja um array
|
||||
if (Array.isArray(fetchedImages)) {
|
||||
setImages(fetchedImages);
|
||||
} else {
|
||||
console.warn('Resposta da API não é um array:', fetchedImages);
|
||||
setImages([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao carregar imagens:', error);
|
||||
toast({
|
||||
title: "Erro ao carregar histórico",
|
||||
description: error.message || "Não foi possível carregar o histórico de imagens.",
|
||||
variant: "destructive",
|
||||
});
|
||||
setImages([]);
|
||||
} finally {
|
||||
setIsLoadingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Carrega imagens ao montar e quando a paginação mudar
|
||||
useEffect(() => {
|
||||
loadImages();
|
||||
}, [currentPage, perPage]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
// Valida a descrição antes de enviar
|
||||
const validation = imageGenerationService.validateDescription(prompt);
|
||||
if (!validation.valid) {
|
||||
toast({
|
||||
title: "Descrição inválida",
|
||||
description: validation.error,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const handleGenerate = () => {
|
||||
setIsGenerating(true);
|
||||
// Simulate image generation
|
||||
setTimeout(() => {
|
||||
const newImage: GeneratedImage = {
|
||||
id: Date.now().toString(),
|
||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
|
||||
prompt: prompt,
|
||||
|
||||
try {
|
||||
// Chama o serviço de geração de imagem
|
||||
const response = await imageGenerationService.generateImage({
|
||||
description: prompt,
|
||||
size: selectedSize,
|
||||
timestamp: "Agora",
|
||||
});
|
||||
|
||||
// Verifica se a geração foi bem-sucedida
|
||||
if (response.success && response.image_url && response.image_generation_id) {
|
||||
toast({
|
||||
title: "Imagem gerada com sucesso",
|
||||
description: `Tamanho: ${IMAGE_SIZE_OPTIONS[selectedSize].label}`,
|
||||
});
|
||||
|
||||
// Cria objeto da imagem recém-gerada para exibição imediata
|
||||
const newGeneratedImage: ImageRecord = {
|
||||
id: response.image_generation_id,
|
||||
user_email: '', // Será preenchido pelo backend
|
||||
estabelecimento_id: 0, // Será preenchido pelo backend
|
||||
description: response.message, // Descrição original
|
||||
model: 'dall-e-3', // Modelo padrão
|
||||
image_url: response.image_url,
|
||||
size: selectedSize,
|
||||
cost_usd: '0',
|
||||
total_tokens: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setImages([newImage, ...images]);
|
||||
setIsGenerating(false);
|
||||
|
||||
// Salva a última imagem gerada para exibição
|
||||
setLastGeneratedImage(newGeneratedImage);
|
||||
|
||||
// Limpa o campo de descrição após sucesso
|
||||
setPrompt("");
|
||||
}, 3000);
|
||||
|
||||
// Recarrega a lista de imagens (sem aguardar para não bloquear a UI)
|
||||
loadImages(1, perPage);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
// Se success for false ou faltarem dados, trata como erro
|
||||
throw {
|
||||
message: response.message || 'Erro ao gerar imagem',
|
||||
code: response.code,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro na geração de imagem:', error);
|
||||
|
||||
// Exibe a mensagem de erro exata retornada pela API
|
||||
const errorMessage = error.message || "Não foi possível gerar a imagem. Tente novamente.";
|
||||
const errorCode = error.code ? ` (${error.code})` : '';
|
||||
|
||||
toast({
|
||||
title: "Erro na geração",
|
||||
description: errorMessage + errorCode,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setImages(images.filter((img) => img.id !== id));
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const result = await imageGenerationService.deleteImage(id);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "Imagem removida",
|
||||
description: "A imagem foi removida com sucesso.",
|
||||
});
|
||||
|
||||
// Se a imagem deletada for a última gerada, limpa o preview
|
||||
if (lastGeneratedImage && lastGeneratedImage.id === id) {
|
||||
setLastGeneratedImage(null);
|
||||
}
|
||||
|
||||
// Recarrega a lista de imagens
|
||||
await loadImages();
|
||||
} else {
|
||||
throw new Error(result.message || 'Erro ao deletar imagem');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao deletar imagem:', error);
|
||||
toast({
|
||||
title: "Erro ao remover",
|
||||
description: error.message || "Não foi possível remover a imagem.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredImages = images.filter((img) =>
|
||||
img.prompt.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const handleDownload = async (image: ImageRecord) => {
|
||||
try {
|
||||
await imageGenerationService.downloadImage(
|
||||
image.image_url,
|
||||
`${image.description.substring(0, 30)}_${image.size}.png`
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Download iniciado",
|
||||
description: "A imagem está sendo baixada.",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erro no download",
|
||||
description: "Não foi possível baixar a imagem.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(parseInt(value));
|
||||
setCurrentPage(1); // Reset para primeira página ao mudar itens por página
|
||||
};
|
||||
|
||||
const filteredImages = Array.isArray(images)
|
||||
? images.filter((img) =>
|
||||
img.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||
<ChatHeader />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
<Tabs defaultValue="generate" className="space-y-6">
|
||||
<div className="max-w-5xl mx-auto p-3 md:p-6">
|
||||
<Tabs defaultValue="generate" className="space-y-4 md:space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2 glass-effect">
|
||||
<TabsTrigger value="generate">Gerar Imagem</TabsTrigger>
|
||||
<TabsTrigger value="history">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Histórico
|
||||
<TabsTrigger value="generate" className="text-xs md:text-sm">Gerar Imagem</TabsTrigger>
|
||||
<TabsTrigger value="history" className="text-xs md:text-sm">
|
||||
<Clock className="w-3 h-3 md:w-4 md:h-4 mr-1 md:mr-2" />
|
||||
<span className="hidden md:inline">Histórico</span>
|
||||
<span className="md:hidden">Hist.</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Generate Tab */}
|
||||
<TabsContent value="generate" className="space-y-6">
|
||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
||||
<TabsContent value="generate" className="space-y-4 md:space-y-6">
|
||||
<div className="glass-effect rounded-xl p-4 md:p-6 space-y-3 md:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Descreva a imagem
|
||||
@@ -101,19 +245,19 @@ export const ImageView = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Tamanho
|
||||
</label>
|
||||
<Select value={selectedSize} onValueChange={setSelectedSize}>
|
||||
<Select value={selectedSize} onValueChange={(value) => setSelectedSize(value as ImageSize)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="glass-effect bg-popover border-border z-50">
|
||||
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
|
||||
<SelectItem value="1024x1536">1024x1536 (Retrato)</SelectItem>
|
||||
<SelectItem value="1536x1024">1536x1024 (Paisagem)</SelectItem>
|
||||
<SelectItem value="1024x1792">1024x1792 (Retrato)</SelectItem>
|
||||
<SelectItem value="1792x1024">1792x1024 (Paisagem)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -149,12 +293,13 @@ export const ImageView = () => {
|
||||
)}
|
||||
|
||||
{/* Recent Images Preview */}
|
||||
{!isGenerating && images.length > 0 && (
|
||||
{!isGenerating && lastGeneratedImage && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
|
||||
<ImageCard
|
||||
image={images[0]}
|
||||
image={lastGeneratedImage}
|
||||
onDelete={handleDelete}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -162,8 +307,9 @@ export const ImageView = () => {
|
||||
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
@@ -172,18 +318,82 @@ export const ImageView = () => {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="glass-effect bg-popover border-border z-50">
|
||||
<SelectItem value="5">5 por página</SelectItem>
|
||||
<SelectItem value="10">10 por página</SelectItem>
|
||||
<SelectItem value="20">20 por página</SelectItem>
|
||||
<SelectItem value="50">50 por página</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoadingImages ? (
|
||||
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
|
||||
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground">Carregando imagens...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Images Grid */}
|
||||
{filteredImages.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredImages.map((image) => (
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
onDelete={handleDelete}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!searchQuery && images.length >= perPage && (
|
||||
<div className="flex items-center justify-center gap-4 mt-6">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Anterior</span>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<span className="text-sm text-muted-foreground px-4">
|
||||
Página {currentPage}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={images.length < perPage}
|
||||
className="gap-1"
|
||||
>
|
||||
<span className="hidden md:inline">Próxima</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="glass-effect rounded-xl p-12 text-center">
|
||||
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
@@ -192,6 +402,8 @@ export const ImageView = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -202,31 +414,96 @@ export const ImageView = () => {
|
||||
};
|
||||
|
||||
interface ImageCardProps {
|
||||
image: GeneratedImage;
|
||||
image: ImageRecord;
|
||||
onDelete: (id: string) => void;
|
||||
onDownload: (image: ImageRecord) => void;
|
||||
}
|
||||
|
||||
const ImageCard = ({ image, onDelete }: ImageCardProps) => {
|
||||
const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
// Calcula tempo relativo
|
||||
const getRelativeTime = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp);
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Agora';
|
||||
if (minutes < 60) return `Há ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||
if (hours < 24) return `Há ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||
return `Há ${days} ${days === 1 ? 'dia' : 'dias'}`;
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
console.warn('Erro CORS ao carregar imagem:', image.image_url);
|
||||
setImageError(true);
|
||||
setImageLoading(false);
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
console.log('Imagem carregada com sucesso:', image.image_url);
|
||||
setImageLoading(false);
|
||||
setImageError(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
|
||||
<div className="relative aspect-square">
|
||||
{imageLoading && !imageError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{imageError ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted text-muted-foreground p-4">
|
||||
<Sparkles className="w-12 h-12 mb-2 opacity-50" />
|
||||
<p className="text-sm text-center font-medium mb-1">Erro ao carregar</p>
|
||||
<p className="text-xs text-center mb-2">A imagem foi gerada, mas não pode ser exibida aqui</p>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<a
|
||||
href={image.image_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-center bg-primary text-primary-foreground px-3 py-2 rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Abrir imagem em nova aba
|
||||
</a>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(image.image_url)}
|
||||
className="text-xs text-center bg-secondary text-secondary-foreground px-3 py-1 rounded-md hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Copiar URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.prompt}
|
||||
src={image.image_url}
|
||||
alt={image.description}
|
||||
className="w-full h-full object-cover"
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
|
||||
<p className="text-sm text-white line-clamp-2">{image.prompt}</p>
|
||||
<p className="text-sm text-white line-clamp-2">{image.description}</p>
|
||||
<div className="flex items-center justify-between text-xs text-white/70">
|
||||
<span>{image.size}</span>
|
||||
<span>{image.timestamp}</span>
|
||||
<span>{IMAGE_SIZE_OPTIONS[image.size as ImageSize]?.label || image.size}</span>
|
||||
<span>{getRelativeTime(image.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="flex-1 gap-1"
|
||||
onClick={() => onDownload(image)}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
Baixar
|
||||
|
||||
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 há 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">
|
||||
> 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -27,12 +27,18 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
hideClose?: boolean;
|
||||
overlayClassName?: string;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, hideClose, overlayClassName, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -42,10 +48,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Configuração de modelos de IA disponíveis
|
||||
* Mapeia os nomes de exibição para os IDs usados pela API
|
||||
*/
|
||||
|
||||
export interface ModelConfig {
|
||||
name: string; // Nome exibido na interface
|
||||
id: string; // ID enviado para a API
|
||||
description?: string; // Descrição opcional do modelo
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para modelo retornado pela API do banco de dados
|
||||
*/
|
||||
export interface ModelIA {
|
||||
id: number;
|
||||
provider_id: number;
|
||||
name: string;
|
||||
model_identifier: string;
|
||||
cost_input_per_million: string;
|
||||
cost_output_per_million: string;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de modelos disponíveis
|
||||
* NOTA: Estes dados correspondem à tabela de modelos do banco de dados.
|
||||
* Futuramente será integrado com API para buscar dinamicamente.
|
||||
*/
|
||||
export const AVAILABLE_MODELS: ModelConfig[] = [
|
||||
{
|
||||
name: 'GPT-4.1',
|
||||
id: '1',
|
||||
description: 'Modelo GPT-4.1',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o',
|
||||
id: '2',
|
||||
description: 'Modelo GPT-4o',
|
||||
},
|
||||
{
|
||||
name: 'GPT-5 Mini',
|
||||
id: '3',
|
||||
description: 'Modelo GPT-5 Mini',
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.0 Flash',
|
||||
id: '4',
|
||||
description: 'Google Gemini 2.0 Flash',
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4.5',
|
||||
id: '5',
|
||||
description: 'Anthropic Claude Sonnet 4.5',
|
||||
},
|
||||
{
|
||||
name: 'DeepSeek V3.2 Chat',
|
||||
id: '6',
|
||||
description: 'DeepSeek V3.2 Chat',
|
||||
},
|
||||
{
|
||||
name: 'DeepSeek V3.2 Reasoner',
|
||||
id: '7',
|
||||
description: 'DeepSeek V3.2 Reasoner',
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.5 Flash',
|
||||
id: '8',
|
||||
description: 'Google Gemini 2.5 Flash',
|
||||
},
|
||||
{
|
||||
name: 'Gemini 2.5 Flash-Lite',
|
||||
id: '9',
|
||||
description: 'Google Gemini 2.5 Flash-Lite',
|
||||
},
|
||||
{
|
||||
name: 'Claude Haiku 4.5',
|
||||
id: '10',
|
||||
description: 'Anthropic Claude Haiku 4.5',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o Mini',
|
||||
id: '11',
|
||||
description: 'Modelo GPT-4o Mini',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapeia o nome do modelo para o ID da API
|
||||
*
|
||||
* @param modelName - Nome do modelo exibido na interface
|
||||
* @returns ID do modelo para a API, ou o próprio nome se não encontrado
|
||||
*/
|
||||
export function getModelId(modelName: string): string {
|
||||
const model = AVAILABLE_MODELS.find(m => m.name === modelName);
|
||||
return model?.id || modelName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapeia o ID da API para o nome de exibição
|
||||
*
|
||||
* @param modelId - ID do modelo na API
|
||||
* @returns Nome do modelo para exibir, ou o próprio ID se não encontrado
|
||||
*/
|
||||
export function getModelName(modelId: string): string {
|
||||
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
|
||||
return model?.name || modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém a configuração completa de um modelo pelo nome
|
||||
*
|
||||
* @param modelName - Nome do modelo
|
||||
* @returns Configuração do modelo ou undefined
|
||||
*/
|
||||
export function getModelConfig(modelName: string): ModelConfig | undefined {
|
||||
return AVAILABLE_MODELS.find(m => m.name === modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna apenas os nomes dos modelos para uso em dropdowns
|
||||
*/
|
||||
export function getModelNames(): string[] {
|
||||
return AVAILABLE_MODELS.map(m => m.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca modelos de IA disponíveis no banco de dados
|
||||
*
|
||||
* @returns Promise com array de modelos ativos
|
||||
*/
|
||||
export async function fetchModelsFromDatabase(): Promise<ModelConfig[]> {
|
||||
try {
|
||||
// Importa dinamicamente para evitar circular dependency
|
||||
const { apiService } = await import('@/services/api');
|
||||
|
||||
const response = await apiService.get<ModelIA[]>('/webhook/codex/get_models_ia');
|
||||
|
||||
console.log('Modelos carregados da API:', response.data);
|
||||
|
||||
// Filtra apenas modelos ativos e converte para ModelConfig
|
||||
return response.data
|
||||
.filter(model => model.is_active === 1)
|
||||
.map((model: ModelIA) => ({
|
||||
name: model.name,
|
||||
id: model.id.toString(),
|
||||
description: `${model.model_identifier}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar modelos da API:', error);
|
||||
|
||||
// Fallback para modelos hardcoded em caso de erro
|
||||
console.warn('Usando modelos hardcoded como fallback');
|
||||
return AVAILABLE_MODELS;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -31,6 +31,12 @@
|
||||
--destructive: 0 84% 60%;
|
||||
--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%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 190 100% 45%;
|
||||
@@ -56,6 +62,11 @@
|
||||
/* Animation speeds */
|
||||
--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);
|
||||
--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 {
|
||||
@@ -84,6 +95,12 @@
|
||||
--destructive: 0 84% 60%;
|
||||
--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%;
|
||||
--input: 222 30% 18%;
|
||||
--ring: 190 100% 50%;
|
||||
@@ -103,6 +120,22 @@
|
||||
--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-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 {
|
||||
.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 {
|
||||
@@ -152,4 +185,139 @@
|
||||
.gradient-text {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
-30
@@ -1,46 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { ChatView } from "@/components/chat/ChatView";
|
||||
import { ImageView } from "@/components/images/ImageView";
|
||||
import { AudioView } from "@/components/audio/AudioView";
|
||||
import { BotView } from "@/components/bots/BotView";
|
||||
import { BotChat } from "@/components/bots/BotChat";
|
||||
import { AgentView } from "@/components/agent/AgentView";
|
||||
import { TranscriptionView } from "@/components/audio/TranscriptionView";
|
||||
import { GenerationView } from "@/components/audio/GenerationView";
|
||||
import { PromptsView } from "@/components/prompts/PromptsView";
|
||||
import { PromptsFormView } from "../components/prompts/PromptsFormView";
|
||||
import { AreasView } from "@/components/areas/AreasView";
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "prompts" | "parecerJuridico" | "areas";
|
||||
|
||||
const PATH_TO_TAB: Record<string, TabType> = {
|
||||
"/codex/bate-papo": "chat",
|
||||
"/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 Index = () => {
|
||||
const [activeView, setActiveView] = useState<"chat" | "images" | "audio" | "bots" | "agent">("chat");
|
||||
const [activeBotChat, setActiveBotChat] = useState<Bot | null>(null);
|
||||
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",
|
||||
};
|
||||
|
||||
const handleStartBotChat = (bot: Bot) => {
|
||||
setActiveBotChat(bot);
|
||||
};
|
||||
function CodexLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeTab = getActiveTabFromPath(location.pathname);
|
||||
|
||||
const handleBackFromBotChat = () => {
|
||||
setActiveBotChat(null);
|
||||
const onTabChange = (tab: TabType) => {
|
||||
navigate(TAB_TO_PATH[tab]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout activeTab={activeView} onTabChange={setActiveView}>
|
||||
{activeView === "chat" && <ChatView />}
|
||||
{activeView === "images" && <ImageView />}
|
||||
{activeView === "audio" && <AudioView />}
|
||||
{activeView === "bots" && (
|
||||
activeBotChat ? (
|
||||
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
|
||||
) : (
|
||||
<BotView onStartChat={handleStartBotChat} />
|
||||
)
|
||||
)}
|
||||
{activeView === "agent" && <AgentView />}
|
||||
<Layout activeTab={activeTab} onTabChange={onTabChange}>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const Index = () => {
|
||||
React.useEffect(() => {
|
||||
if (!GlobalFunctions.isUsuarioLogado()) window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||
}, []);
|
||||
|
||||
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 path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import FullScreenLoader from "@/components/FullScreenLoader";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
import { RedirectService } from "./RedirectService";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
|
||||
|
||||
export default function Redirect() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
let { gsikey } = useParams();
|
||||
|
||||
React.useEffect(() => {
|
||||
let error: number = 0;
|
||||
sessionStorage.setItem('gsikey', gsikey ?? '0');
|
||||
RedirectService.loadTransferArea(parseInt(gsikey ?? '0')).then((response) => {
|
||||
//console.log(response);
|
||||
error = response.data.errorCode;
|
||||
//msg = response.data.errorMessage;
|
||||
if(response.data.errorCode == 0){
|
||||
sessionStorage.setItem('TRANSFER_AREA',JSON.stringify(response.data.areTrs));
|
||||
sessionStorage.setItem('usuarioLogado',JSON.stringify({
|
||||
token: response.data.jwt.token,
|
||||
refreshToken: response.data.jwt.refreshToken
|
||||
}));
|
||||
}
|
||||
// else if(error !== 1401){
|
||||
// navigate('/',{replace: true});
|
||||
// }
|
||||
})
|
||||
.catch((_err) => {
|
||||
navigate('/404',{replace: true});
|
||||
})
|
||||
.finally(() => {
|
||||
if(error == 0 || error === 1401){
|
||||
navigate(`/${GlobalFunctions.getRedirectComponent()}`,{replace: true});
|
||||
}
|
||||
// else if(error === 1401){
|
||||
// navigate({pathname: '/',search:`?p=${window.btoa(msg)}`},{replace: true});
|
||||
// }
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FullScreenLoader show={true} text="Carregando...Aguarde..." />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||
import axios from "axios";
|
||||
const endPoint: string = 'v1/login/';
|
||||
|
||||
export class RedirectService{
|
||||
|
||||
public static loadTransferArea(gsikey: number): Promise<any>{
|
||||
return axios.post(`${import.meta.env.VITE_BASE_URL_OPEN_CODEX_APIs}${endPoint}login_by_gsikey`,{
|
||||
"gsikey" : gsikey
|
||||
});
|
||||
}
|
||||
|
||||
public static async validaAcessoApp(urlDestino: string): Promise<any>{
|
||||
//let token = await GlobalFunctions.getToken();
|
||||
let usuLogado = GlobalFunctions.getUsuarioLogado();
|
||||
let usuCod: string = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioCodigo);
|
||||
let estCod: string = usuLogado.EID;
|
||||
|
||||
let urlDestinoPost: string = urlDestino;//urlDestino.trim().length < 1 ? GlobalFunctions.getTransferProperty(TransferAreaProperties.urlDestino) : urlDestino;
|
||||
if(urlDestinoPost.trim().length < 1){
|
||||
try {
|
||||
let pathSplit = window.location.pathname.split('/');
|
||||
|
||||
urlDestinoPost = `/${pathSplit[pathSplit.length-2]}/${pathSplit[pathSplit.length-1]}`;
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
//const heads = { headers: {'Content-Type':'application/json; charset=utf-8','Authorization': `Bearer ${token}`} };
|
||||
const heads = { headers: {'Content-Type':'application/json; charset=utf-8'} };
|
||||
return axios.post(`${import.meta.env.VITE_BASE_URL_HGTX_CORE_API}v1/aplicativos/validar_acesso_app?usuCod=${usuCod}&estCod=${estCod}`,{
|
||||
"urlDestino": urlDestinoPost
|
||||
},heads);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
export interface CreateOpinionResponse {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
file_url?: string;
|
||||
file_url_melhoria?: string;
|
||||
}
|
||||
|
||||
export interface CreateOpinionRequest {
|
||||
titulo: string;
|
||||
categoria: string;
|
||||
instrucoes: string;
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
export type OpinionStatus = 'processando' | 'concluido' | 'erro';
|
||||
|
||||
export interface OpinionRecord {
|
||||
id: string;
|
||||
estabelecimento_id: number;
|
||||
user_email: string;
|
||||
titulo: string;
|
||||
categoria: string;
|
||||
instrucoes: string;
|
||||
file_url: string;
|
||||
created_at: string;
|
||||
file_url_melhoria: string;
|
||||
status?: OpinionStatus;
|
||||
isLocalPending?: boolean;
|
||||
}
|
||||
|
||||
export interface GetOpinionsParams {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
export interface GetOpinionsResponse {
|
||||
data: OpinionRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
type ApiErrorShape = { message?: string; status?: number };
|
||||
|
||||
class AgentService {
|
||||
private readonly CREATE_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
|
||||
private readonly OPINIONS_BASE = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex';
|
||||
|
||||
private resolveUserContext(userEmail?: string, estabelecimentoId?: number) {
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||
return { email, estabId };
|
||||
}
|
||||
|
||||
private assertUserContext(email: unknown, estabId: unknown) {
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
private toApiError(error: unknown): never {
|
||||
const e = error as ApiErrorShape;
|
||||
throw {
|
||||
success: false,
|
||||
message: e?.message || 'Erro desconhecido',
|
||||
status: e?.status,
|
||||
};
|
||||
}
|
||||
|
||||
async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
|
||||
const { titulo, categoria, instrucoes, userEmail, estabelecimentoId } = request;
|
||||
|
||||
if (!titulo?.trim()) {
|
||||
throw { success: false, message: 'Título do parecer é obrigatório' };
|
||||
}
|
||||
|
||||
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 {
|
||||
const response = await apiService.post<CreateOpinionResponse>(
|
||||
this.CREATE_ENDPOINT,
|
||||
{
|
||||
user_email: email,
|
||||
estabelecimento_id: estabId,
|
||||
titulo: titulo.trim(),
|
||||
categoria: categoria.trim(),
|
||||
instrucoes: instrucoes.trim(),
|
||||
},
|
||||
{ timeout: 600_000 }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao criar parecer:', error);
|
||||
this.toApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> {
|
||||
const { page = 1, per_page = 10, search = '', userEmail, estabelecimentoId } = params || {};
|
||||
|
||||
const { email, estabId } = this.resolveUserContext(userEmail, estabelecimentoId);
|
||||
this.assertUserContext(email, estabId);
|
||||
|
||||
try {
|
||||
const url = `${this.OPINIONS_BASE}/get_parecer/${email}/${estabId}`;
|
||||
|
||||
const response = await apiService.get<OpinionRecord[] | GetOpinionsResponse>(url, {
|
||||
params: { page, per_page, search },
|
||||
});
|
||||
|
||||
const raw = response.data;
|
||||
|
||||
if (Array.isArray(raw)) return raw;
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
if (Array.isArray((raw as GetOpinionsResponse).data)) return (raw as GetOpinionsResponse).data;
|
||||
if (Array.isArray((raw as { opinions?: OpinionRecord[] }).opinions)) {
|
||||
return (raw as unknown as { opinions: OpinionRecord[] }).opinions;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar pareceres:', error);
|
||||
this.toApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadOpinion(fileUrl: string, fileName: string): Promise<void> {
|
||||
if (!fileUrl) {
|
||||
throw { success: false, message: 'URL do arquivo não fornecida' };
|
||||
}
|
||||
|
||||
const triggerDownload = (href: string) => {
|
||||
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);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(fileUrl, { method: 'GET', mode: 'cors', cache: 'no-cache' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erro HTTP: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
triggerDownload(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const url = new URL(fileUrl);
|
||||
url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
||||
triggerDownload(url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> {
|
||||
if (!opinionId) {
|
||||
throw { success: false, message: 'ID do parecer não fornecido' };
|
||||
}
|
||||
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
|
||||
if (!email) {
|
||||
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${this.OPINIONS_BASE}/delete_parecer/${email}/${opinionId}`;
|
||||
const response = await apiService.delete<{ success: boolean }[]>(url);
|
||||
|
||||
if (Array.isArray(response.data) && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao excluir parecer:', error);
|
||||
this.toApiError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const agentService = new AgentService();
|
||||
@@ -0,0 +1,69 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
class ApiService {
|
||||
private readonly axiosInstance: AxiosInstance;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = import.meta.env.VITE_API_KEY || '';
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
timeout: 60_000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
this.axiosInstance.interceptors.request.use((config) => {
|
||||
if (this.apiKey) {
|
||||
config.headers['apikey'] = this.apiKey;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API Error:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
return Promise.reject({
|
||||
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.get<T>(url, config);
|
||||
}
|
||||
|
||||
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.post<T>(url, data, config);
|
||||
}
|
||||
|
||||
postFormData<T = unknown>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.post<T>(url, formData, {
|
||||
...config,
|
||||
headers: { ...config?.headers, 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.put<T>(url, data, config);
|
||||
}
|
||||
|
||||
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.delete<T>(url, config);
|
||||
}
|
||||
|
||||
getInstance(): AxiosInstance {
|
||||
return this.axiosInstance;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,268 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
|
||||
|
||||
export interface AudioGenerationResponse {
|
||||
success: boolean;
|
||||
audio_url: string;
|
||||
audio_generation_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AudioRecord {
|
||||
id: string;
|
||||
user_email: string;
|
||||
estabelecimento_id: number;
|
||||
input_text: string;
|
||||
model: string;
|
||||
voice: VoiceType;
|
||||
audio_url: string;
|
||||
duration_seconds: number | null;
|
||||
file_size: number;
|
||||
cost_usd: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AudioGenerationRequest {
|
||||
message: string;
|
||||
voice: VoiceType;
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
export const VOICE_OPTIONS = {
|
||||
alloy: {
|
||||
label: 'Alloy',
|
||||
gender: 'Masculina',
|
||||
style: 'Neutra, equilibrada, tom corporativo',
|
||||
description: 'Boa para tutoriais e comunicações institucionais.',
|
||||
},
|
||||
echo: {
|
||||
label: 'Echo',
|
||||
gender: 'Masculina',
|
||||
style: 'Forte e profissional, mais grave',
|
||||
description: 'Ideal para voz de autoridade ou locução firme.',
|
||||
},
|
||||
fable: {
|
||||
label: 'Fable',
|
||||
gender: 'Feminina',
|
||||
style: 'Narrativa, calorosa e envolvente',
|
||||
description: 'Ótima para storytelling e áudios empáticos.',
|
||||
},
|
||||
onyx: {
|
||||
label: 'Onyx',
|
||||
gender: 'Masculina',
|
||||
style: 'Grave, autoritária, impactante',
|
||||
description: 'Excelente para trailers, mensagens sérias ou institucionais.',
|
||||
},
|
||||
nova: {
|
||||
label: 'Nova',
|
||||
gender: 'Feminina',
|
||||
style: 'Brilhante, animada, energética',
|
||||
description: 'Boa para vídeos curtos, marketing ou conteúdos leves.',
|
||||
},
|
||||
shimmer: {
|
||||
label: 'Shimmer',
|
||||
gender: 'Feminina',
|
||||
style: 'Suave, otimista, clara',
|
||||
description: 'Boa para mensagens acolhedoras, explicações e IA conversacional.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ApiErrorShape = { message?: string; status?: number };
|
||||
|
||||
class AudioGenerationService {
|
||||
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 DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio';
|
||||
|
||||
private resolveEmail(userEmail?: string): string {
|
||||
return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
}
|
||||
|
||||
private toApiError(error: unknown, fallback: string): never {
|
||||
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 [];
|
||||
}
|
||||
|
||||
isValidVoice(voice: string): voice is VoiceType {
|
||||
return Object.keys(VOICE_OPTIONS).includes(voice);
|
||||
}
|
||||
|
||||
getVoiceInfo(voice: VoiceType) {
|
||||
return VOICE_OPTIONS[voice];
|
||||
}
|
||||
|
||||
getAllVoices() {
|
||||
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
|
||||
value: key as VoiceType,
|
||||
...info,
|
||||
}));
|
||||
}
|
||||
|
||||
validateText(text: string, maxLength = 4096): { valid: boolean; error?: string } {
|
||||
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` };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
|
||||
const { message, voice } = request;
|
||||
|
||||
if (!message?.trim()) {
|
||||
throw { success: false, message: 'O texto não pode estar vazio' };
|
||||
}
|
||||
|
||||
if (!this.isValidVoice(voice)) {
|
||||
throw { success: false, message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}` };
|
||||
}
|
||||
|
||||
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||
|
||||
try {
|
||||
const response = await apiService.post<AudioGenerationResponse>(
|
||||
this.AUDIO_GENERATION_ENDPOINT,
|
||||
{ estabelecimento_id: estabId, user_email: email, message, voice }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro na geração de áudio:', error);
|
||||
this.toApiError(error, 'Erro ao gerar áudio');
|
||||
}
|
||||
}
|
||||
|
||||
async getAudios(userEmail?: string, page = 1, perPage = 10): Promise<AudioRecord[]> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
|
||||
if (!email) {
|
||||
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
}
|
||||
|
||||
try {
|
||||
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 }>>(
|
||||
`${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}`
|
||||
);
|
||||
const result = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
return { success: result.success ?? true, message: result.message || 'Áudio deletado com sucesso' };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao deletar áudio:', error);
|
||||
this.toApiError(error, 'Erro ao deletar áudio');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Baixa o arquivo no navegador (sem navegar para a URL).
|
||||
* Usa blob local + <a download> — necessário porque links cross-origin ignoram `download` e abrem a URL.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const audioGenerationService = new AudioGenerationService();
|
||||
@@ -0,0 +1,433 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
export interface ChatResponse {
|
||||
success: boolean;
|
||||
response: string;
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageRequest {
|
||||
chatId: string;
|
||||
message: string;
|
||||
modelId: string;
|
||||
personalidade?: string;
|
||||
anexos?: File[];
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
export interface StoredChat {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model?: string;
|
||||
timestamp: Date;
|
||||
attachments?: Array<{ name: string; type: string; size: number }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StoredFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
chatIds: string[];
|
||||
}
|
||||
|
||||
export interface FolderRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
model_id: number;
|
||||
folder_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
personalidade: string;
|
||||
estabelecimento_id: number;
|
||||
}
|
||||
|
||||
export interface GetChatsAndFoldersResponse {
|
||||
chats: ChatRecord[];
|
||||
folders: FolderRecord[];
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model_id: number;
|
||||
model_name?: string;
|
||||
has_attachments: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_usd: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ApiErrorShape = { message?: string; status?: number };
|
||||
type ApiArrayResult = Array<{ success: boolean; [key: string]: unknown }>;
|
||||
|
||||
class ChatService {
|
||||
private readonly CHAT_ENDPOINT = '/webhook/codex/message';
|
||||
private readonly STORAGE_KEY_CHATS = 'hgtx_chats';
|
||||
private readonly STORAGE_KEY_FOLDERS = 'hgtx_folders';
|
||||
private readonly POST_FOLDER_ENDPOINT = '/webhook/codex/post_folders';
|
||||
private readonly GET_CHATS_FOLDERS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_chat_folders';
|
||||
private readonly PUT_CHAT_IN_FOLDER_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/insert_chat_in_folder';
|
||||
private readonly DELETE_FOLDER_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_chat_folder';
|
||||
private readonly DELETE_CHAT_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_chat_messages';
|
||||
private readonly GET_CHAT_MESSAGES_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_chat_messages';
|
||||
|
||||
private readonly ALLOWED_FILE_TYPES = {
|
||||
'application/pdf': { ext: '.pdf', label: 'PDF' },
|
||||
'image/png': { ext: '.png', label: 'PNG' },
|
||||
'image/jpeg': { ext: '.jpg, .jpeg', label: 'JPEG' },
|
||||
'image/webp': { ext: '.webp', label: 'WebP' },
|
||||
};
|
||||
|
||||
private readonly MAX_ATTACHMENTS = 5;
|
||||
|
||||
private resolveEmail(userEmail?: string): string {
|
||||
return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
}
|
||||
|
||||
private toApiError(error: unknown, fallback: string): never {
|
||||
const e = error as ApiErrorShape;
|
||||
throw { success: false, message: e?.message || fallback, status: e?.status };
|
||||
}
|
||||
|
||||
private firstResult(data: unknown): { success: boolean } {
|
||||
if (Array.isArray(data) && data.length > 0) return data[0] as { success: boolean };
|
||||
return data as { success: boolean };
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
generateChatId(): string {
|
||||
const ts = Date.now().toString(36);
|
||||
const r1 = Math.random().toString(36).substring(2, 15);
|
||||
const r2 = Math.random().toString(36).substring(2, 15);
|
||||
return `chat_${ts}_${r1}${r2}`;
|
||||
}
|
||||
|
||||
generateChatTitle(message: string, maxLength = 50): string {
|
||||
if (!message?.trim()) return 'Nova Conversa';
|
||||
const trimmed = message.trim();
|
||||
return trimmed.length <= maxLength ? trimmed : `${trimmed.substring(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
validateAttachments(files: File[]): { valid: boolean; error?: string } {
|
||||
if (files.length > this.MAX_ATTACHMENTS) {
|
||||
return { valid: false, error: `Máximo de ${this.MAX_ATTACHMENTS} anexos permitidos. Você selecionou ${files.length}.` };
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!this.ALLOWED_FILE_TYPES[file.type as keyof typeof this.ALLOWED_FILE_TYPES]) {
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||
const extValid = Object.values(this.ALLOWED_FILE_TYPES).some((t) => t.ext.includes(ext));
|
||||
if (!extValid) {
|
||||
const allowed = Object.values(this.ALLOWED_FILE_TYPES).map((t) => t.label).join(', ');
|
||||
return { valid: false, error: `Arquivo "${file.name}" não é permitido. Formatos aceitos: ${allowed}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
getAllowedFileTypes() {
|
||||
return Object.entries(this.ALLOWED_FILE_TYPES).map(([mimeType, info]) => ({ mimeType, ...info }));
|
||||
}
|
||||
|
||||
getAllowedFileTypesLabel(): string {
|
||||
return Object.values(this.ALLOWED_FILE_TYPES).map((t) => t.label).join(', ');
|
||||
}
|
||||
|
||||
getAllowedFileExtensions(): string {
|
||||
return Object.values(this.ALLOWED_FILE_TYPES).map((t) => t.ext).join(',');
|
||||
}
|
||||
|
||||
getMaxAttachments(): number {
|
||||
return this.MAX_ATTACHMENTS;
|
||||
}
|
||||
|
||||
saveChat(chat: StoredChat): void {
|
||||
try {
|
||||
const chats = this.getAllChats();
|
||||
const idx = chats.findIndex((c) => c.id === chat.id);
|
||||
if (idx >= 0) {
|
||||
chats[idx] = { ...chat, updatedAt: new Date() };
|
||||
} else {
|
||||
chats.push(chat);
|
||||
}
|
||||
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(chats));
|
||||
} catch {
|
||||
throw new Error('Não foi possível salvar o chat');
|
||||
}
|
||||
}
|
||||
|
||||
getChat(chatId: string): StoredChat | undefined {
|
||||
return this.getAllChats().find((c) => c.id === chatId);
|
||||
}
|
||||
|
||||
getAllChats(): StoredChat[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY_CHATS);
|
||||
if (!raw) return [];
|
||||
return (JSON.parse(raw) as StoredChat[]).map((chat) => ({
|
||||
...chat,
|
||||
createdAt: new Date(chat.createdAt),
|
||||
updatedAt: new Date(chat.updatedAt),
|
||||
messages: chat.messages.map((msg) => ({ ...msg, timestamp: new Date(msg.timestamp) })),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
deleteChatLocal(chatId: string): void {
|
||||
try {
|
||||
const filtered = this.getAllChats().filter((c) => c.id !== chatId);
|
||||
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(filtered));
|
||||
} catch {
|
||||
throw new Error('Não foi possível deletar o chat');
|
||||
}
|
||||
}
|
||||
|
||||
saveFolder(folder: StoredFolder): void {
|
||||
try {
|
||||
const folders = this.getAllFolders();
|
||||
const idx = folders.findIndex((f) => f.id === folder.id);
|
||||
if (idx >= 0) {
|
||||
folders[idx] = folder;
|
||||
} else {
|
||||
folders.push(folder);
|
||||
}
|
||||
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(folders));
|
||||
} catch {
|
||||
throw new Error('Não foi possível salvar a pasta');
|
||||
}
|
||||
}
|
||||
|
||||
getAllFolders(): StoredFolder[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY_FOLDERS);
|
||||
if (!raw) return [];
|
||||
return (JSON.parse(raw) as StoredFolder[]).map((f) => ({ ...f, createdAt: new Date(f.createdAt) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
deleteFolderLocal(folderId: string): void {
|
||||
try {
|
||||
const filtered = this.getAllFolders().filter((f) => f.id !== folderId);
|
||||
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(filtered));
|
||||
} catch {
|
||||
throw new Error('Não foi possível deletar a pasta');
|
||||
}
|
||||
}
|
||||
|
||||
clearAllChats(): void {
|
||||
localStorage.removeItem(this.STORAGE_KEY_CHATS);
|
||||
}
|
||||
|
||||
clearAllFolders(): void {
|
||||
localStorage.removeItem(this.STORAGE_KEY_FOLDERS);
|
||||
}
|
||||
|
||||
async sendMessage(request: ChatMessageRequest): Promise<ChatResponse> {
|
||||
const { chatId, message, modelId, personalidade, anexos } = request;
|
||||
|
||||
if (!chatId) throw { success: false, message: 'ID do chat é obrigatório' };
|
||||
if (!message?.trim()) throw { success: false, message: 'A mensagem não pode estar vazia' };
|
||||
if (!modelId) throw { success: false, message: 'Modelo da IA é obrigatório' };
|
||||
if (anexos && anexos.length > this.MAX_ATTACHMENTS) {
|
||||
throw { success: false, message: `Máximo de ${this.MAX_ATTACHMENTS} arquivos anexos permitidos` };
|
||||
}
|
||||
|
||||
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('estabelecimento_id', estabId.toString());
|
||||
formData.append('chat_id', chatId);
|
||||
formData.append('user_email', email);
|
||||
formData.append('model_id', modelId);
|
||||
formData.append('message', message);
|
||||
|
||||
if (personalidade?.trim()) {
|
||||
formData.append('personalidade', personalidade);
|
||||
}
|
||||
|
||||
anexos?.forEach((file, i) => {
|
||||
if (i < this.MAX_ATTACHMENTS) formData.append(`anexo${i + 1}`, file);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiService.postFormData<ChatResponse>(this.CHAT_ENDPOINT, formData);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao enviar mensagem:', error);
|
||||
this.toApiError(error, 'Erro ao comunicar com a IA');
|
||||
}
|
||||
}
|
||||
|
||||
async createFolder(name: string, userEmail?: string): Promise<{ success: boolean }> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
if (!name?.trim()) throw { success: false, message: 'Nome da pasta não pode estar vazio' };
|
||||
|
||||
try {
|
||||
const response = await apiService.post<ApiArrayResult>(
|
||||
this.POST_FOLDER_ENDPOINT,
|
||||
{ user_email: email, name: name.trim() }
|
||||
);
|
||||
const result = this.firstResult(response.data);
|
||||
return { success: result.success ?? true };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao criar pasta:', error);
|
||||
this.toApiError(error, 'Erro ao criar pasta');
|
||||
}
|
||||
}
|
||||
|
||||
async renameFolder(folderId: string, newName: string, userEmail?: string): Promise<{ success: boolean }> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
if (!folderId?.trim()) throw { success: false, message: 'ID da pasta não pode estar vazio' };
|
||||
if (!newName?.trim()) throw { success: false, message: 'Nome da pasta não pode estar vazio' };
|
||||
|
||||
try {
|
||||
const response = await apiService.put<ApiArrayResult>(
|
||||
this.POST_FOLDER_ENDPOINT,
|
||||
{ id: folderId, user_email: email, name: newName.trim() }
|
||||
);
|
||||
const result = this.firstResult(response.data);
|
||||
return { success: result.success ?? true };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao renomear pasta:', error);
|
||||
this.toApiError(error, 'Erro ao renomear pasta');
|
||||
}
|
||||
}
|
||||
|
||||
async getChatsAndFolders(userEmail?: string): Promise<GetChatsAndFoldersResponse> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.get<Array<{ result: GetChatsAndFoldersResponse }>>(
|
||||
`${this.GET_CHATS_FOLDERS_ENDPOINT}/${email}`
|
||||
);
|
||||
|
||||
let result: GetChatsAndFoldersResponse;
|
||||
const data = response.data;
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
result = data[0].result;
|
||||
} else if (data && typeof data === 'object' && 'result' in data) {
|
||||
result = (data as { result: GetChatsAndFoldersResponse }).result;
|
||||
} else {
|
||||
result = { chats: [], folders: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
chats: Array.isArray(result.chats) ? result.chats : [],
|
||||
folders: Array.isArray(result.folders) ? result.folders : [],
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar chats e pastas:', error);
|
||||
this.toApiError(error, 'Erro ao buscar chats e pastas');
|
||||
}
|
||||
}
|
||||
|
||||
async moveChatToFolder(chatId: string, folderId: string): Promise<{ success: boolean }> {
|
||||
if (!chatId || !folderId) {
|
||||
throw { success: false, message: 'Chat ID e Folder ID são obrigatórios' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiService.put<ApiArrayResult>(
|
||||
`${this.PUT_CHAT_IN_FOLDER_ENDPOINT}/${folderId}/${chatId}`
|
||||
);
|
||||
const result = this.firstResult(response.data);
|
||||
return { success: result.success ?? true };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao mover chat para pasta:', error);
|
||||
this.toApiError(error, 'Erro ao mover chat para pasta');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFolder(folderId: string, userEmail?: string): Promise<{ success: boolean }> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
if (!folderId) throw { success: false, message: 'ID da pasta não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.delete<ApiArrayResult>(
|
||||
`${this.DELETE_FOLDER_ENDPOINT}/${email}/${folderId}`
|
||||
);
|
||||
const result = this.firstResult(response.data);
|
||||
return { success: result.success ?? true };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao deletar pasta:', error);
|
||||
this.toApiError(error, 'Erro ao deletar pasta');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChat(chatId: string, userEmail?: string): Promise<{ success: boolean }> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
if (!chatId) throw { success: false, message: 'ID do chat não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.delete<ApiArrayResult>(
|
||||
`${this.DELETE_CHAT_ENDPOINT}/${email}/${chatId}`
|
||||
);
|
||||
const result = this.firstResult(response.data);
|
||||
return { success: result.success ?? true };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao deletar chat:', error);
|
||||
this.toApiError(error, 'Erro ao deletar chat');
|
||||
}
|
||||
}
|
||||
|
||||
async getChatMessages(chatId: string, userEmail?: string): Promise<MessageRecord[]> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
if (!chatId) throw { success: false, message: 'ID do chat não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.get<unknown>(
|
||||
`${this.GET_CHAT_MESSAGES_ENDPOINT}/${email}/${chatId}`
|
||||
);
|
||||
return this.extractArray<MessageRecord>(response.data, ['messages', 'data']);
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar mensagens:', error);
|
||||
this.toApiError(error, 'Erro ao buscar mensagens');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const chatService = new ChatService();
|
||||
@@ -0,0 +1,201 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
success: boolean;
|
||||
image_url?: string;
|
||||
image_generation_id?: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
id: string;
|
||||
user_email: string;
|
||||
estabelecimento_id: number;
|
||||
description: string;
|
||||
model: string;
|
||||
image_url: string;
|
||||
size: ImageSize;
|
||||
cost_usd: string;
|
||||
total_tokens: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GetImagesResponse {
|
||||
images: ImageRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
description: string;
|
||||
size: ImageSize;
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
export const IMAGE_SIZE_OPTIONS = {
|
||||
'1024x1024': {
|
||||
label: 'Quadrado',
|
||||
dimensions: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
description: 'Ideal para avatares, ícones e posts em redes sociais',
|
||||
},
|
||||
'1024x1792': {
|
||||
label: 'Retrato',
|
||||
dimensions: '1024x1792',
|
||||
aspectRatio: '9:16',
|
||||
description: 'Perfeito para stories, wallpapers verticais e reels',
|
||||
},
|
||||
'1792x1024': {
|
||||
label: 'Paisagem',
|
||||
dimensions: '1792x1024',
|
||||
aspectRatio: '16:9',
|
||||
description: 'Ótimo para banners, capas e thumbnails de vídeos',
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ApiErrorShape = { message?: string; status?: number; success?: boolean; code?: string };
|
||||
|
||||
class ImageGenerationService {
|
||||
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 DELETE_IMAGE_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_images';
|
||||
|
||||
private resolveEmail(userEmail?: string): string {
|
||||
return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
}
|
||||
|
||||
private toApiError(error: unknown, fallback: string): never {
|
||||
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 [];
|
||||
}
|
||||
|
||||
isValidSize(size: string): size is ImageSize {
|
||||
return Object.keys(IMAGE_SIZE_OPTIONS).includes(size);
|
||||
}
|
||||
|
||||
getSizeInfo(size: ImageSize) {
|
||||
return IMAGE_SIZE_OPTIONS[size];
|
||||
}
|
||||
|
||||
getAllSizes() {
|
||||
return Object.entries(IMAGE_SIZE_OPTIONS).map(([key, info]) => ({
|
||||
value: key as ImageSize,
|
||||
...info,
|
||||
}));
|
||||
}
|
||||
|
||||
validateDescription(description: string, minLength = 3, maxLength = 1000): { valid: boolean; error?: string } {
|
||||
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` };
|
||||
if (description.length > maxLength) return { valid: false, error: `A descrição é muito longa. Máximo: ${maxLength} caracteres` };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResponse> {
|
||||
const { description, size } = request;
|
||||
|
||||
if (!description?.trim()) {
|
||||
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> {
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `imagem_${Date.now()}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
throw new Error('Não foi possível baixar a imagem');
|
||||
}
|
||||
}
|
||||
|
||||
async getImages(userEmail?: string, page = 1, perPage = 10): Promise<ImageRecord[]> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
|
||||
if (!email) {
|
||||
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiService.get<unknown>(
|
||||
`${this.GET_IMAGES_ENDPOINT}/${email}`,
|
||||
{ params: { page: page.toString(), per_page: perPage.toString() } }
|
||||
);
|
||||
return this.extractArray<ImageRecord>(response.data, ['images', 'data']);
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar imagens:', error);
|
||||
this.toApiError(error, 'Erro ao buscar imagens');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteImage(imageId: 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 (!imageId) throw { success: false, message: 'ID da imagem não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
|
||||
`${this.DELETE_IMAGE_ENDPOINT}/${email}/${imageId}`
|
||||
);
|
||||
const result = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
return { success: result.success ?? true, message: result.message || 'Imagem deletada com sucesso' };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao deletar imagem:', error);
|
||||
this.toApiError(error, 'Erro ao deletar imagem');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageGenerationService = new ImageGenerationService();
|
||||
@@ -0,0 +1,33 @@
|
||||
export { apiService } from './api';
|
||||
export { transcriptionService } from './transcription';
|
||||
export { audioGenerationService, VOICE_OPTIONS } from './audioGeneration';
|
||||
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 { AudioGenerationResponse, AudioGenerationRequest, VoiceType } from './audioGeneration';
|
||||
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';
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,128 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
export interface TranscriptionResponse {
|
||||
success: boolean;
|
||||
transcription_id: string;
|
||||
audio_url: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TranscriptionRecord {
|
||||
id: string;
|
||||
user_email: string;
|
||||
estabelecimento_id: number;
|
||||
audio_file_name: string;
|
||||
audio_duration_seconds: number;
|
||||
transcription_text: string;
|
||||
model: string;
|
||||
audio_url: string;
|
||||
cost_usd: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TranscriptionRequest {
|
||||
audioFile: File;
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
type ApiErrorShape = { message?: string; status?: number };
|
||||
|
||||
class TranscriptionService {
|
||||
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 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'];
|
||||
|
||||
private resolveEmail(userEmail?: string): string {
|
||||
return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
}
|
||||
|
||||
private toApiError(error: unknown, fallback: string): never {
|
||||
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);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('data', audioFile);
|
||||
formData.append('user_email', email);
|
||||
formData.append('estabelecimento_id', estabId.toString());
|
||||
|
||||
try {
|
||||
const response = await apiService.postFormData<TranscriptionResponse>(
|
||||
this.TRANSCRIPTION_ENDPOINT,
|
||||
formData
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro na transcrição:', error);
|
||||
this.toApiError(error, 'Erro ao transcrever áudio');
|
||||
}
|
||||
}
|
||||
|
||||
async getTranscriptions(userEmail?: string, page = 1, perPage = 10): Promise<TranscriptionRecord[]> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.get<unknown>(
|
||||
`${this.GET_TRANSCRIPTIONS_ENDPOINT}/${email}`,
|
||||
{ params: { page: page.toString(), per_page: perPage.toString() } }
|
||||
);
|
||||
return this.extractArray<TranscriptionRecord>(response.data, ['transcriptions', 'data']);
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar transcrições:', error);
|
||||
this.toApiError(error, 'Erro ao buscar transcrições');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTranscription(transcriptionId: 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 (!transcriptionId) throw { success: false, message: 'ID da transcrição não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
|
||||
`${this.DELETE_TRANSCRIPTION_ENDPOINT}/${email}/${transcriptionId}`
|
||||
);
|
||||
const result = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
return { success: result.success ?? true, message: result.message || 'Transcrição deletada com sucesso' };
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao deletar transcrição:', error);
|
||||
this.toApiError(error, 'Erro ao deletar transcrição');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const transcriptionService = new TranscriptionService();
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
message: string;
|
||||
status?: number;
|
||||
data?: any;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -1 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_API_KEY: string
|
||||
readonly VITE_USER_EMAIL: string
|
||||
readonly VITE_ESTABELECIMENTO_ID: string
|
||||
readonly VITE_BASE_URL_OPEN_CODEX_APIs: string
|
||||
readonly VITE_BASE_URL_HGTX_CORE_API: string
|
||||
readonly VITE_BASE_URL_HGTX_CORE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
+9
-1
@@ -34,6 +34,14 @@ export default {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
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: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
@@ -103,5 +111,5 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
} satisfies Config;
|
||||
|
||||
@@ -8,6 +8,14 @@ export default defineConfig(({ mode }) => ({
|
||||
server: {
|
||||
host: "::",
|
||||
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),
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user