Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a01c6f97b | |||
| 8f2e4f1d21 | |||
| 8d8818c32e | |||
| 6537e2dd53 | |||
| 6bf7e7d42e | |||
| 39032ea621 | |||
| d75d80e9c7 | |||
| 189a2a8f2c | |||
| 50aca767d7 |
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1411
-26
File diff suppressed because it is too large
Load Diff
+5
-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,7 +40,9 @@
|
||||
"@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",
|
||||
@@ -49,11 +51,13 @@
|
||||
"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",
|
||||
|
||||
+8
-1
@@ -15,7 +15,14 @@ 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 />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain } from "lucide-react";
|
||||
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, FileText, Layers } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
@@ -7,11 +7,11 @@ import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
|
||||
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "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" | "transcription" | "generation" | "bots" | "agent";
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
|
||||
|
||||
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
@@ -21,8 +21,10 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
{ id: "images" as TabType, label: "Imagens", icon: Image },
|
||||
{ id: "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: "agent" as TabType, label: "Agente de Parecer", icon: Brain },
|
||||
//{ 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 (
|
||||
|
||||
@@ -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) => {
|
||||
@@ -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>
|
||||
|
||||
@@ -134,18 +134,23 @@ export const GenerationView = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAudio = (audio: AudioRecord) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = audio.audio_url;
|
||||
a.download = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
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) => {
|
||||
@@ -290,10 +295,11 @@ export const GenerationView = () => {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => handleDownloadAudio(lastGeneratedAudio)}
|
||||
onClick={() => void handleDownloadAudio(lastGeneratedAudio)}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
Baixar
|
||||
@@ -385,9 +391,10 @@ export const GenerationView = () => {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDownloadAudio(audio)}
|
||||
onClick={() => void handleDownloadAudio(audio)}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -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";
|
||||
@@ -53,7 +54,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex-1 max-w-3xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
|
||||
<div className={`flex-1 max-w-4xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
|
||||
{/* Attachments */}
|
||||
{attachments && attachments.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-3 md:px-4 py-2 md:py-3 rounded-xl ${
|
||||
className={`inline-block max-w-[90%] px-3 md:px-4 py-2 md:py-3 rounded-xl break-words ${
|
||||
isAssistant
|
||||
? "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={`text-sm md:text-base 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 && (
|
||||
|
||||
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,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;
|
||||
}
|
||||
@@ -62,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 {
|
||||
@@ -115,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +245,79 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-43
@@ -1,63 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { ChatView } from "@/components/chat/ChatView";
|
||||
import { ImageView } from "@/components/images/ImageView";
|
||||
import { TranscriptionView } from "@/components/audio/TranscriptionView";
|
||||
import { GenerationView } from "@/components/audio/GenerationView";
|
||||
import { BotView } from "@/components/bots/BotView";
|
||||
import { BotChat } from "@/components/bots/BotChat";
|
||||
import { AgentView } from "@/components/agent/AgentView";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
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 TAB_TO_PATH: Record<TabType, string> = {
|
||||
chat: "/codex/bate-papo",
|
||||
images: "/codex/imagens",
|
||||
transcription: "/codex/transcricao-audio",
|
||||
generation: "/codex/geracao-audio",
|
||||
prompts: "/codex/prompts",
|
||||
parecerJuridico: "/codex/parecer-juridico",
|
||||
areas: "/codex/areas",
|
||||
};
|
||||
|
||||
function CodexLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeTab = getActiveTabFromPath(location.pathname);
|
||||
|
||||
const onTabChange = (tab: TabType) => {
|
||||
navigate(TAB_TO_PATH[tab]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout activeTab={activeTab} onTabChange={onTabChange}>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const Index = () => {
|
||||
const [activeView, setActiveView] = useState<"chat" | "images" | "transcription" | "generation" | "bots" | "agent">("chat");
|
||||
const [activeBotChat, setActiveBotChat] = useState<Bot | null>(null);
|
||||
|
||||
const handleStartBotChat = (bot: Bot) => {
|
||||
setActiveBotChat(bot);
|
||||
};
|
||||
|
||||
const handleBackFromBotChat = () => {
|
||||
setActiveBotChat(null);
|
||||
};
|
||||
|
||||
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">
|
||||
<Route path="" element={
|
||||
<Layout activeTab={activeView} onTabChange={setActiveView}>
|
||||
{activeView === "chat" && <ChatView />}
|
||||
{activeView === "images" && <ImageView />}
|
||||
{activeView === "transcription" && <TranscriptionView />}
|
||||
{activeView === "generation" && <GenerationView />}
|
||||
{activeView === "bots" && (
|
||||
activeBotChat ? (
|
||||
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
|
||||
) : (
|
||||
<BotView onStartChat={handleStartBotChat} />
|
||||
)
|
||||
)}
|
||||
{activeView === "agent" && <AgentView />}
|
||||
</Layout>
|
||||
} />
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="" element={<Navigate to="/404" replace />} />
|
||||
<Route path="codex" element={<PromptsProvider><CodexLayout /></PromptsProvider>}>
|
||||
<Route index element={<Navigate to="bate-papo" replace />} />
|
||||
<Route path="bate-papo" element={<ChatView />} />
|
||||
<Route path="imagens" element={<ImageView />} />
|
||||
<Route path="transcricao-audio" element={<TranscriptionView />} />
|
||||
<Route path="geracao-audio" element={<GenerationView />} />
|
||||
<Route path="parecer-juridico" element={<ParecerJuridicoView />} />
|
||||
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
|
||||
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
|
||||
<Route path="prompts" element={<PromptsView />} />
|
||||
<Route path="prompts/novo" element={<PromptsFormView />} />
|
||||
<Route path="prompts/:id" element={<PromptsFormView />} />
|
||||
<Route path="areas" element={<AreasView />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to={`/404`} replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+72
-250
@@ -1,9 +1,6 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
/**
|
||||
* Interface para resposta do POST de parecer técnico
|
||||
*/
|
||||
export interface CreateOpinionResponse {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
@@ -11,9 +8,6 @@ export interface CreateOpinionResponse {
|
||||
file_url_melhoria?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para requisição de criação de parecer
|
||||
*/
|
||||
export interface CreateOpinionRequest {
|
||||
titulo: string;
|
||||
categoria: string;
|
||||
@@ -22,14 +16,8 @@ export interface CreateOpinionRequest {
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status do parecer
|
||||
*/
|
||||
export type OpinionStatus = 'processando' | 'concluido' | 'erro';
|
||||
|
||||
/**
|
||||
* Interface para um parecer retornado pela API
|
||||
*/
|
||||
export interface OpinionRecord {
|
||||
id: string;
|
||||
estabelecimento_id: number;
|
||||
@@ -40,13 +28,10 @@ export interface OpinionRecord {
|
||||
file_url: string;
|
||||
created_at: string;
|
||||
file_url_melhoria: string;
|
||||
status?: OpinionStatus; // Status do processamento do parecer (pode vir da API ou ser local)
|
||||
isLocalPending?: boolean; // Flag para indicar se é um registro local temporário
|
||||
status?: OpinionStatus;
|
||||
isLocalPending?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para parâmetros de paginação e busca
|
||||
*/
|
||||
export interface GetOpinionsParams {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
@@ -55,9 +40,6 @@ export interface GetOpinionsParams {
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para resposta do GET de pareceres
|
||||
*/
|
||||
export interface GetOpinionsResponse {
|
||||
data: OpinionRecord[];
|
||||
total: number;
|
||||
@@ -65,74 +47,53 @@ export interface GetOpinionsResponse {
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serviço para gerenciamento de pareceres jurídicos
|
||||
*/
|
||||
type ApiErrorShape = { message?: string; status?: number };
|
||||
|
||||
class AgentService {
|
||||
private readonly CREATE_OPINION_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
|
||||
private readonly GET_OPINIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_parecer';
|
||||
private readonly CREATE_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
|
||||
private readonly OPINIONS_BASE = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex';
|
||||
|
||||
/**
|
||||
* Cria um novo parecer técnico
|
||||
*
|
||||
* @param request - Dados do parecer
|
||||
* @returns Promise com a resposta da API
|
||||
*/
|
||||
async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
|
||||
const {
|
||||
titulo,
|
||||
categoria,
|
||||
instrucoes,
|
||||
userEmail,
|
||||
estabelecimentoId
|
||||
} = request;
|
||||
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
private resolveUserContext(userEmail?: string, estabelecimentoId?: number) {
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||
|
||||
// Validações
|
||||
if (!titulo || titulo.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Título do parecer é obrigatório',
|
||||
};
|
||||
}
|
||||
|
||||
if (!instrucoes || instrucoes.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Instruções são obrigatórias',
|
||||
};
|
||||
return { email, estabId };
|
||||
}
|
||||
|
||||
private assertUserContext(email: unknown, estabId: unknown) {
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário não fornecido',
|
||||
};
|
||||
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: 'ID do estabelecimento não fornecido',
|
||||
message: e?.message || 'Erro desconhecido',
|
||||
status: e?.status,
|
||||
};
|
||||
}
|
||||
|
||||
// Log para debug
|
||||
console.log('Criando parecer:', {
|
||||
titulo,
|
||||
categoria,
|
||||
instrucoesLength: instrucoes.length,
|
||||
userEmail: email,
|
||||
estabelecimentoId: estabId,
|
||||
});
|
||||
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 {
|
||||
// Faz a requisição usando o serviço de API com timeout de 5 minutos
|
||||
const response = await apiService.post<CreateOpinionResponse>(
|
||||
this.CREATE_OPINION_ENDPOINT,
|
||||
this.CREATE_ENDPOINT,
|
||||
{
|
||||
user_email: email,
|
||||
estabelecimento_id: estabId,
|
||||
@@ -140,245 +101,106 @@ class AgentService {
|
||||
categoria: categoria.trim(),
|
||||
instrucoes: instrucoes.trim(),
|
||||
},
|
||||
{
|
||||
timeout: 600000, // 5 minutos para geração de parecer (processo demorado)
|
||||
}
|
||||
{ timeout: 600_000 }
|
||||
);
|
||||
|
||||
console.log('Resposta da API (criar parecer):', response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Trata erros específicos
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao criar parecer:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao criar parecer',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca todos os pareceres do usuário com paginação e busca
|
||||
*
|
||||
* @param params - Parâmetros de paginação e busca
|
||||
* @returns Promise com array de pareceres
|
||||
*/
|
||||
async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> {
|
||||
const {
|
||||
page = 1,
|
||||
per_page = 10,
|
||||
search = '',
|
||||
userEmail,
|
||||
estabelecimentoId
|
||||
} = params || {};
|
||||
const { page = 1, per_page = 10, search = '', userEmail, estabelecimentoId } = params || {};
|
||||
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
if (!estabId) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'ID do estabelecimento não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Buscando pareceres:', {
|
||||
userEmail: email,
|
||||
estabelecimentoId: estabId,
|
||||
page,
|
||||
per_page,
|
||||
search,
|
||||
});
|
||||
const { email, estabId } = this.resolveUserContext(userEmail, estabelecimentoId);
|
||||
this.assertUserContext(email, estabId);
|
||||
|
||||
try {
|
||||
// Constrói a URL com parâmetros de query
|
||||
const url = `${this.GET_OPINIONS_ENDPOINT}/${email}/${estabId}`;
|
||||
const url = `${this.OPINIONS_BASE}/get_parecer/${email}/${estabId}`;
|
||||
|
||||
const response = await apiService.get<OpinionRecord[]>(url, {
|
||||
params: {
|
||||
page,
|
||||
per_page,
|
||||
search,
|
||||
},
|
||||
const response = await apiService.get<OpinionRecord[] | GetOpinionsResponse>(url, {
|
||||
params: { page, per_page, search },
|
||||
});
|
||||
|
||||
console.log('Resposta da API (buscar pareceres):', response.data);
|
||||
const raw = response.data;
|
||||
|
||||
// A API retorna diretamente o array de pareceres
|
||||
// Garante que sempre retorna um array
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
// Se a resposta for um objeto, tenta encontrar o array dentro dele
|
||||
console.warn('API retornou objeto em vez de array:', response.data);
|
||||
if (Array.isArray(raw)) return raw;
|
||||
|
||||
if (Array.isArray((response.data as any).data)) {
|
||||
return (response.data as any).data;
|
||||
} else if (Array.isArray((response.data as any).opinions)) {
|
||||
return (response.data as any).opinions;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Se não conseguir extrair array, retorna vazio
|
||||
console.warn('Não foi possível extrair array de pareceres da resposta');
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar pareceres:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao buscar pareceres',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz download de um arquivo de parecer
|
||||
*
|
||||
* @param fileUrl - URL do arquivo a ser baixado
|
||||
* @param fileName - Nome do arquivo para download
|
||||
*/
|
||||
async downloadOpinion(fileUrl: string, fileName: string): Promise<void> {
|
||||
if (!fileUrl) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'URL do arquivo não fornecida',
|
||||
};
|
||||
throw { success: false, message: 'URL do arquivo não fornecida' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Iniciando download:', { fileUrl, fileName });
|
||||
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);
|
||||
};
|
||||
|
||||
// Tenta primeiro fazer o download via fetch (funciona se CORS estiver configurado)
|
||||
try {
|
||||
const response = await fetch(fileUrl, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
});
|
||||
const response = await fetch(fileUrl, { method: 'GET', mode: 'cors', cache: 'no-cache' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erro HTTP: ${response.status}`);
|
||||
}
|
||||
|
||||
// Converte a resposta em blob
|
||||
const blob = await response.blob();
|
||||
|
||||
// Cria uma URL temporária para o blob
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Cria um elemento <a> temporário para forçar o download
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Remove o elemento e libera a URL temporária
|
||||
document.body.removeChild(link);
|
||||
triggerDownload(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
|
||||
console.log('Download via fetch concluído:', { fileUrl, fileName });
|
||||
return;
|
||||
} catch (fetchError: any) {
|
||||
console.warn('Erro no download via fetch, tentando método alternativo:', fetchError.message);
|
||||
|
||||
// Se falhar (erro de CORS), usa o método alternativo de abrir em nova aba
|
||||
// Isso permite que o navegador force o download mesmo com restrições de CORS
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
// Para S3, podemos tentar adicionar parâmetros que forçam o download
|
||||
} catch {
|
||||
const url = new URL(fileUrl);
|
||||
url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
||||
link.href = url.toString();
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
console.log('Download via link direto iniciado:', { fileUrl, fileName });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao fazer download:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao fazer download do arquivo. Verifique se a URL está acessível.',
|
||||
};
|
||||
triggerDownload(url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclui um parecer técnico
|
||||
*
|
||||
* @param opinionId - ID do parecer a ser excluído
|
||||
* @param userEmail - Email do usuário (opcional, usa GlobalFunctions se não fornecido)
|
||||
* @returns Promise com a resposta da API
|
||||
*/
|
||||
async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> {
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
if (!opinionId) {
|
||||
throw { success: false, message: 'ID do parecer não fornecido' };
|
||||
}
|
||||
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
|
||||
if (!opinionId) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'ID do parecer não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário não fornecido',
|
||||
};
|
||||
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
}
|
||||
|
||||
console.log('Excluindo parecer:', {
|
||||
opinionId,
|
||||
userEmail: email,
|
||||
});
|
||||
|
||||
try {
|
||||
// Constrói a URL com o user_email e id
|
||||
const url = `/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_parecer/${email}/${opinionId}`;
|
||||
|
||||
const url = `${this.OPINIONS_BASE}/delete_parecer/${email}/${opinionId}`;
|
||||
const response = await apiService.delete<{ success: boolean }[]>(url);
|
||||
|
||||
console.log('Resposta da API (excluir parecer):', response.data);
|
||||
|
||||
// A API retorna um array com { success: true }
|
||||
if (Array.isArray(response.data) && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao excluir parecer:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao excluir parecer',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exporta instância única (Singleton)
|
||||
export const agentService = new AgentService();
|
||||
|
||||
+14
-72
@@ -1,63 +1,34 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
/**
|
||||
* Configuração centralizada da API
|
||||
* Todas as chamadas de API devem usar este serviço para garantir
|
||||
* autenticação e configuração consistente
|
||||
*/
|
||||
class ApiService {
|
||||
private axiosInstance: AxiosInstance;
|
||||
private apiKey: string;
|
||||
private baseURL: string;
|
||||
private readonly axiosInstance: AxiosInstance;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor() {
|
||||
// Busca configurações das variáveis de ambiente
|
||||
this.apiKey = import.meta.env.VITE_API_KEY || '';
|
||||
this.baseURL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
// Validação das variáveis de ambiente
|
||||
if (!this.apiKey) {
|
||||
console.error('VITE_API_KEY não configurada no arquivo .env');
|
||||
}
|
||||
if (!this.baseURL) {
|
||||
console.error('VITE_API_BASE_URL não configurada no arquivo .env');
|
||||
}
|
||||
|
||||
// Cria instância do Axios com configurações padrão
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 60000, // 60 segundos para upload de arquivos
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseURL,
|
||||
timeout: 60_000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Interceptor para adicionar API Key em todas as requisições
|
||||
this.axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
// Adiciona a API Key no header de todas as requisições
|
||||
this.axiosInstance.interceptors.request.use((config) => {
|
||||
if (this.apiKey) {
|
||||
config.headers['apikey'] = this.apiKey;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Interceptor de resposta para tratamento centralizado de erros
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Log de erro para debug
|
||||
console.error('API Error:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
});
|
||||
|
||||
// Retorna erro formatado
|
||||
return Promise.reject({
|
||||
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
|
||||
status: error.response?.status,
|
||||
@@ -67,61 +38,32 @@ class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requisição GET
|
||||
*/
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.get<T>(url, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requisição POST
|
||||
*/
|
||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.post<T>(url, data, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requisição POST com FormData (para upload de arquivos)
|
||||
*/
|
||||
async postFormData<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
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',
|
||||
},
|
||||
headers: { ...config?.headers, 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requisição PUT
|
||||
*/
|
||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.put<T>(url, data, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requisição DELETE
|
||||
*/
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.delete<T>(url, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a URL base configurada
|
||||
*/
|
||||
getBaseURL(): string {
|
||||
return this.baseURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a instância do Axios (uso avançado)
|
||||
*/
|
||||
getInstance(): AxiosInstance {
|
||||
return this.axiosInstance;
|
||||
}
|
||||
}
|
||||
|
||||
// Exporta instância única (Singleton)
|
||||
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();
|
||||
+66
-223
@@ -1,3 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { GlobalFunctions } from '@/GlobalFunctions';
|
||||
|
||||
export interface AsanaWorkspaceResponse {
|
||||
gid: string;
|
||||
resource_type: string;
|
||||
@@ -27,6 +30,7 @@ export interface AsanaUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AsanaIntegrationResponse {
|
||||
success: boolean;
|
||||
integracao_id?: string;
|
||||
@@ -47,298 +51,137 @@ export interface AsanaIntegrationRequest {
|
||||
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';
|
||||
|
||||
async getWorkspaces(token: string): Promise<AsanaWorkspace[]> {
|
||||
if (!token || token.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Token do Asana é obrigatório',
|
||||
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 axios = (await import('axios')).default;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'accept': 'application/json',
|
||||
'authorization': `Bearer ${token.trim()}`,
|
||||
};
|
||||
|
||||
const response = await axios.get<AsanaWorkspacesResponse>(
|
||||
`${this.ASANA_BASE_URL}/workspaces`,
|
||||
{ headers }
|
||||
{ headers: this.asanaHeaders(token) }
|
||||
);
|
||||
|
||||
return response.data.data.map((workspace) => ({
|
||||
id: workspace.gid,
|
||||
name: workspace.name,
|
||||
}));
|
||||
return response.data.data.map((w) => ({ id: w.gid, name: w.name }));
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar workspaces do Asana:', error);
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
||||
|
||||
if (axiosError.response?.status === 401) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Token inválido ou expirado. Verifique sua chave de API.',
|
||||
};
|
||||
}
|
||||
|
||||
if (axiosError.response?.data) {
|
||||
throw {
|
||||
success: false,
|
||||
message: axiosError.response.data.message || 'Erro ao buscar workspaces do Asana',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erro ao buscar workspaces do Asana',
|
||||
};
|
||||
this.handleAsanaError(error, 'Erro ao buscar workspaces do Asana');
|
||||
}
|
||||
}
|
||||
|
||||
async getUsers(token: string, workspaceId: string): Promise<AsanaUser[]> {
|
||||
if (!token || token.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Token do Asana é obrigatório',
|
||||
};
|
||||
if (!token?.trim()) {
|
||||
throw { success: false, message: 'Token do Asana é obrigatório' };
|
||||
}
|
||||
|
||||
if (!workspaceId || workspaceId.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'ID do workspace é obrigatório',
|
||||
};
|
||||
if (!workspaceId?.trim()) {
|
||||
throw { success: false, message: 'ID do workspace é obrigatório' };
|
||||
}
|
||||
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'accept': 'application/json',
|
||||
'authorization': `Bearer ${token.trim()}`,
|
||||
};
|
||||
|
||||
const response = await axios.get<AsanaUsersResponse>(
|
||||
`${this.ASANA_BASE_URL}/users?workspace=${workspaceId}`,
|
||||
{ headers }
|
||||
{ headers: this.asanaHeaders(token) }
|
||||
);
|
||||
|
||||
// Converte a resposta da API para o formato esperado pelo componente
|
||||
// A API retorna gid, mas o componente espera id
|
||||
return response.data.data.map((user) => ({
|
||||
id: user.gid,
|
||||
name: user.name,
|
||||
}));
|
||||
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);
|
||||
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
||||
|
||||
if (axiosError.response?.status === 401) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Token inválido ou expirado. Verifique sua chave de API.',
|
||||
};
|
||||
}
|
||||
|
||||
if (axiosError.response?.data) {
|
||||
throw {
|
||||
success: false,
|
||||
message: axiosError.response.data.message || 'Erro ao buscar usuários do Asana',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erro ao buscar usuários do Asana',
|
||||
};
|
||||
this.handleAsanaError(error, 'Erro ao buscar usuários do Asana');
|
||||
}
|
||||
}
|
||||
|
||||
async getIntegration(userId: string): Promise<AsanaIntegrationResponse | null> {
|
||||
if (!userId || userId.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'ID do usuário é obrigatório',
|
||||
};
|
||||
if (!userId?.trim()) {
|
||||
throw { success: false, message: 'ID do usuário é obrigatório' };
|
||||
}
|
||||
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const { GlobalFunctions } = await import('@/GlobalFunctions');
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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 }
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return response.data.success ? response.data : null;
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar integração do Asana:', error);
|
||||
|
||||
// Se o erro for 404 ou similar, significa que não tem integração cadastrada
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
if (axiosError.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
throw { success: false, message: 'Dados incompletos para criar integração' };
|
||||
}
|
||||
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const { GlobalFunctions } = await import('@/GlobalFunctions');
|
||||
|
||||
// Obtém o token JWT para autenticação
|
||||
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',
|
||||
};
|
||||
|
||||
// Adiciona API key (obrigatória)
|
||||
if (apiKey) {
|
||||
headers['apikey'] = apiKey;
|
||||
}
|
||||
|
||||
// Adiciona token JWT se disponível
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
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`,
|
||||
'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);
|
||||
|
||||
// Retorna o erro da API se existir
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
||||
|
||||
if (axiosError.response?.data) {
|
||||
const errorData = axiosError.response.data;
|
||||
throw {
|
||||
success: false,
|
||||
message: errorData.message || 'Erro ao criar integração do Asana',
|
||||
};
|
||||
this.handleAsanaError(error, 'Erro ao criar integração do Asana');
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erro ao criar integração do Asana',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async updateIntegration(integracaoId: string, request: Omit<AsanaIntegrationRequest, 'user_id'>): Promise<AsanaIntegrationResponse> {
|
||||
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',
|
||||
};
|
||||
throw { success: false, message: 'Dados incompletos para atualizar integração' };
|
||||
}
|
||||
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const { GlobalFunctions } = await import('@/GlobalFunctions');
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/c898beff-84cb-44df-a69c-6eff27ccd7aa/codex/agente-pessoal/integracoes/asana/${integracaoId}`;
|
||||
|
||||
const headers = await this.n8nHeaders();
|
||||
const response = await axios.post<AsanaIntegrationResponse>(
|
||||
url,
|
||||
`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);
|
||||
|
||||
// Retorna o erro da API se existir
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
||||
|
||||
if (axiosError.response?.data) {
|
||||
const errorData = axiosError.response.data;
|
||||
throw {
|
||||
success: false,
|
||||
message: errorData.message || 'Erro ao atualizar integração do Asana',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erro ao atualizar integração do Asana',
|
||||
};
|
||||
this.handleAsanaError(error, 'Erro ao atualizar integração do Asana');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exporta instância única (Singleton)
|
||||
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();
|
||||
+167
-259
@@ -1,24 +1,15 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
/**
|
||||
* Tipos de vozes disponíveis para geração de áudio
|
||||
*/
|
||||
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
|
||||
|
||||
/**
|
||||
* Interface para a resposta da API de geração de áudio
|
||||
*/
|
||||
export interface AudioGenerationResponse {
|
||||
success: boolean;
|
||||
audio_url: string; // URL do áudio gerado
|
||||
audio_url: string;
|
||||
audio_generation_id: string;
|
||||
message: string; // Texto que foi convertido em áudio
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para um áudio armazenado no banco de dados
|
||||
*/
|
||||
export interface AudioRecord {
|
||||
id: string;
|
||||
user_email: string;
|
||||
@@ -33,9 +24,6 @@ export interface AudioRecord {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para os dados necessários para geração de áudio
|
||||
*/
|
||||
export interface AudioGenerationRequest {
|
||||
message: string;
|
||||
voice: VoiceType;
|
||||
@@ -43,143 +31,80 @@ export interface AudioGenerationRequest {
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Informações sobre cada tipo de voz disponível
|
||||
*/
|
||||
export const VOICE_OPTIONS = {
|
||||
alloy: {
|
||||
label: "Alloy",
|
||||
gender: "Masculina",
|
||||
style: "Neutra, equilibrada, tom corporativo",
|
||||
description: "Boa para tutoriais e comunicações institucionais."
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
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."
|
||||
}
|
||||
label: 'Shimmer',
|
||||
gender: 'Feminina',
|
||||
style: 'Suave, otimista, clara',
|
||||
description: 'Boa para mensagens acolhedoras, explicações e IA conversacional.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Serviço de geração de áudio (Text-to-Speech)
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* Gera um arquivo de áudio a partir de texto
|
||||
*
|
||||
* @param request - Dados da requisição (texto, voz, email, estabelecimento)
|
||||
* @returns Promise com a resposta da API
|
||||
*/
|
||||
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
|
||||
const { message, voice, userEmail, estabelecimentoId } = request;
|
||||
|
||||
// Usa valores do .env se não forem fornecidos
|
||||
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
||||
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
|
||||
|
||||
// Valida o texto
|
||||
if (!message || message.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'O texto não pode estar vazio',
|
||||
};
|
||||
private resolveEmail(userEmail?: string): string {
|
||||
return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
}
|
||||
|
||||
// Valida a voz
|
||||
if (!this.isValidVoice(voice)) {
|
||||
throw {
|
||||
success: false,
|
||||
message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}`,
|
||||
};
|
||||
private toApiError(error: unknown, fallback: string): never {
|
||||
const e = error as ApiErrorShape;
|
||||
throw { success: false, message: e?.message || fallback, status: e?.status };
|
||||
}
|
||||
|
||||
// Log para debug (remover em produção se necessário)
|
||||
console.log('Gerando áudio:', {
|
||||
messageLength: message.length,
|
||||
voice,
|
||||
userEmail: email,
|
||||
estabelecimentoId: estabId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Faz a requisição usando o serviço de API
|
||||
const response = await apiService.post<AudioGenerationResponse>(
|
||||
this.AUDIO_GENERATION_ENDPOINT,
|
||||
{
|
||||
estabelecimento_id: estabId,
|
||||
user_email: email,
|
||||
message: message,
|
||||
voice: voice,
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Trata erros específicos
|
||||
console.error('Erro na geração de áudio:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao gerar áudio',
|
||||
status: error.status,
|
||||
};
|
||||
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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida se a voz selecionada é suportada
|
||||
*
|
||||
* @param voice - Voz a ser validada
|
||||
* @returns true se a voz é válida
|
||||
*/
|
||||
isValidVoice(voice: string): voice is VoiceType {
|
||||
return Object.keys(VOICE_OPTIONS).includes(voice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações sobre uma voz específica
|
||||
*
|
||||
* @param voice - Tipo de voz
|
||||
* @returns Informações da voz
|
||||
*/
|
||||
getVoiceInfo(voice: VoiceType) {
|
||||
return VOICE_OPTIONS[voice];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todas as vozes disponíveis
|
||||
*
|
||||
* @returns Array com todas as opções de voz
|
||||
*/
|
||||
getAllVoices() {
|
||||
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
|
||||
value: key as VoiceType,
|
||||
@@ -187,174 +112,157 @@ class AudioGenerationService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida o texto para geração de áudio
|
||||
*
|
||||
* @param text - Texto a ser validado
|
||||
* @param maxLength - Comprimento máximo (padrão: 4096 caracteres)
|
||||
* @returns Objeto com resultado da validação
|
||||
*/
|
||||
validateText(text: string, maxLength: number = 4096): { valid: boolean; error?: string } {
|
||||
if (!text || text.trim().length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'O texto não pode estar vazio',
|
||||
};
|
||||
}
|
||||
|
||||
if (text.length > maxLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `O texto é muito longo. Máximo: ${maxLength} caracteres`,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista áudios do banco de dados com paginação
|
||||
*
|
||||
* @param userEmail - Email do usuário
|
||||
* @param page - Número da página (padrão: 1)
|
||||
* @param perPage - Quantidade de itens por página (padrão: 10)
|
||||
* @returns Promise com o array de áudios
|
||||
*/
|
||||
async getAudios(
|
||||
userEmail?: string,
|
||||
page: number = 1,
|
||||
perPage: number = 10
|
||||
): Promise<AudioRecord[]> {
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
|
||||
const { message, voice } = request;
|
||||
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário não fornecido',
|
||||
};
|
||||
if (!message?.trim()) {
|
||||
throw { success: false, message: 'O texto não pode estar vazio' };
|
||||
}
|
||||
|
||||
console.log('Buscando áudios:', {
|
||||
userEmail: email,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
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 {
|
||||
// Faz a requisição GET com parâmetros na URL e query
|
||||
const response = await apiService.get<AudioRecord[]>(
|
||||
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
|
||||
{
|
||||
params: {
|
||||
page: page.toString(),
|
||||
per_page: perPage.toString(),
|
||||
},
|
||||
}
|
||||
const response = await apiService.post<AudioGenerationResponse>(
|
||||
this.AUDIO_GENERATION_ENDPOINT,
|
||||
{ estabelecimento_id: estabId, user_email: email, message, voice }
|
||||
);
|
||||
|
||||
console.log('Resposta completa da API:', response);
|
||||
console.log('response.data:', response.data);
|
||||
console.log('É array?:', Array.isArray(response.data));
|
||||
|
||||
// A API retorna diretamente o array de áudios
|
||||
// Garante que sempre retorna um array
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
// Se a resposta for um objeto com uma propriedade 'audios' ou similar
|
||||
console.warn('API retornou objeto em vez de array:', response.data);
|
||||
|
||||
// Tenta encontrar o array dentro do objeto
|
||||
if (Array.isArray((response.data as any).audios)) {
|
||||
return (response.data as any).audios;
|
||||
} else if (Array.isArray((response.data as any).data)) {
|
||||
return (response.data as any).data;
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro na geração de áudio:', error);
|
||||
this.toApiError(error, 'Erro ao gerar áudio');
|
||||
}
|
||||
}
|
||||
|
||||
// Se não conseguir extrair array, retorna vazio
|
||||
console.warn('Não foi possível extrair array de áudios da resposta');
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao buscar áudios:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao buscar áudios',
|
||||
status: error.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleta um áudio do banco de dados
|
||||
*
|
||||
* @param audioId - ID do áudio a ser deletado
|
||||
* @param userEmail - Email do usuário (opcional)
|
||||
* @returns Promise com sucesso ou erro
|
||||
*/
|
||||
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
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',
|
||||
};
|
||||
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
}
|
||||
|
||||
if (!audioId) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'ID do áudio não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Deletando áudio:', {
|
||||
audioId,
|
||||
userEmail: email,
|
||||
});
|
||||
|
||||
try {
|
||||
// Faz a requisição DELETE com parâmetros na URL
|
||||
const response = await apiService.get<unknown>(
|
||||
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
|
||||
{ params: { page: page.toString(), per_page: perPage.toString() } }
|
||||
);
|
||||
return this.extractArray<AudioRecord>(response.data, ['audios', 'data']);
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar áudios:', error);
|
||||
this.toApiError(error, 'Erro ao buscar áudios');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
|
||||
const email = this.resolveEmail(userEmail);
|
||||
|
||||
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
if (!audioId) throw { success: false, message: 'ID do áudio não fornecido' };
|
||||
|
||||
try {
|
||||
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
|
||||
`${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}`
|
||||
);
|
||||
|
||||
console.log('Resposta completa do DELETE:', response);
|
||||
console.log('response.data:', response.data);
|
||||
|
||||
// A API retorna um array com um objeto: [{"success":true}]
|
||||
let result: { success: boolean; message?: string };
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
// Se for array, pega o primeiro elemento
|
||||
result = response.data[0];
|
||||
console.log('API retornou array, usando primeiro elemento:', result);
|
||||
} else {
|
||||
// Se for objeto direto
|
||||
result = response.data;
|
||||
console.log('API retornou objeto direto:', result);
|
||||
}
|
||||
|
||||
// Garante que tem a estrutura mínima
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
message: result.message || 'Áudio deletado com sucesso',
|
||||
};
|
||||
} catch (error: any) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao deletar áudio',
|
||||
status: error.status,
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exporta instância única (Singleton)
|
||||
export const audioGenerationService = new AudioGenerationService();
|
||||
|
||||
+235
-790
File diff suppressed because it is too large
Load Diff
+71
-290
@@ -1,25 +1,16 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
/**
|
||||
* Tamanhos de imagem disponíveis
|
||||
*/
|
||||
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
|
||||
|
||||
/**
|
||||
* Interface para a resposta da API de geração de imagens
|
||||
*/
|
||||
export interface ImageGenerationResponse {
|
||||
success: boolean;
|
||||
image_url?: string; // URL da imagem gerada (opcional quando há erro)
|
||||
image_generation_id?: string; // ID da geração (opcional quando há erro)
|
||||
message: string; // Descrição original ou mensagem de erro
|
||||
code?: string; // Código de erro (ex: "server_error", "invalid_request")
|
||||
image_url?: string;
|
||||
image_generation_id?: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para uma imagem armazenada no banco de dados
|
||||
*/
|
||||
export interface ImageRecord {
|
||||
id: string;
|
||||
user_email: string;
|
||||
@@ -35,9 +26,6 @@ export interface ImageRecord {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para resposta paginada de imagens
|
||||
*/
|
||||
export interface GetImagesResponse {
|
||||
images: ImageRecord[];
|
||||
total: number;
|
||||
@@ -46,9 +34,6 @@ export interface GetImagesResponse {
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para os dados necessários para geração de imagem
|
||||
*/
|
||||
export interface ImageGenerationRequest {
|
||||
description: string;
|
||||
size: ImageSize;
|
||||
@@ -56,9 +41,6 @@ export interface ImageGenerationRequest {
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Informações sobre cada tamanho de imagem disponível
|
||||
*/
|
||||
export const IMAGE_SIZE_OPTIONS = {
|
||||
'1024x1024': {
|
||||
label: 'Quadrado',
|
||||
@@ -80,127 +62,41 @@ export const IMAGE_SIZE_OPTIONS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Serviço de geração de imagens com IA
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* Gera uma imagem a partir de uma descrição em texto
|
||||
*
|
||||
* @param request - Dados da requisição (descrição, tamanho, email, estabelecimento)
|
||||
* @returns Promise com a resposta da API
|
||||
*/
|
||||
async generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResponse> {
|
||||
const { description, size, userEmail, estabelecimentoId } = request;
|
||||
|
||||
// Usa valores do .env se não forem fornecidos
|
||||
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
||||
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
|
||||
|
||||
// Valida a descrição
|
||||
if (!description || description.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'A descrição não pode estar vazia',
|
||||
};
|
||||
private resolveEmail(userEmail?: string): string {
|
||||
return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
}
|
||||
|
||||
// Valida o tamanho
|
||||
if (!this.isValidSize(size)) {
|
||||
throw {
|
||||
success: false,
|
||||
message: `Tamanho inválido. Opções disponíveis: ${Object.keys(IMAGE_SIZE_OPTIONS).join(', ')}`,
|
||||
};
|
||||
private toApiError(error: unknown, fallback: string): never {
|
||||
const e = error as ApiErrorShape;
|
||||
throw { success: false, message: e?.message || fallback, status: e?.status };
|
||||
}
|
||||
|
||||
// Log para debug (remover em produção se necessário)
|
||||
console.log('Gerando imagem:', {
|
||||
descriptionLength: description.length,
|
||||
size,
|
||||
userEmail: email,
|
||||
estabelecimentoId: estabId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Faz a requisição usando o serviço de API
|
||||
// Nota: O campo no body é "estabelecito_id" (com typo na API)
|
||||
const response = await apiService.post<ImageGenerationResponse>(
|
||||
this.IMAGE_GENERATION_ENDPOINT,
|
||||
{
|
||||
estabelecimento_id: estabId, // Mantém o typo da API original
|
||||
user_email: email,
|
||||
description: description,
|
||||
size: size,
|
||||
}
|
||||
);
|
||||
|
||||
// Verifica se a resposta indica erro
|
||||
if (!response.data.success) {
|
||||
throw {
|
||||
success: false,
|
||||
message: response.data.message || 'Erro ao gerar imagem',
|
||||
code: response.data.code,
|
||||
};
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Trata erros específicos da API
|
||||
console.error('Erro na geração de imagem:', error);
|
||||
|
||||
// Se o erro já tem a estrutura esperada (veio da validação acima), repassa
|
||||
if (error.success === false && error.message) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Se o erro veio da requisição HTTP, tenta extrair a resposta da API
|
||||
if (error.response?.data) {
|
||||
const apiError = error.response.data;
|
||||
throw {
|
||||
success: false,
|
||||
message: apiError.message || 'Erro ao gerar imagem',
|
||||
code: apiError.code,
|
||||
};
|
||||
}
|
||||
|
||||
// Erro genérico
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao gerar imagem. Tente novamente.',
|
||||
status: error.status,
|
||||
};
|
||||
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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida se o tamanho selecionado é suportado
|
||||
*
|
||||
* @param size - Tamanho a ser validado
|
||||
* @returns true se o tamanho é válido
|
||||
*/
|
||||
isValidSize(size: string): size is ImageSize {
|
||||
return Object.keys(IMAGE_SIZE_OPTIONS).includes(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações sobre um tamanho específico
|
||||
*
|
||||
* @param size - Tamanho da imagem
|
||||
* @returns Informações do tamanho
|
||||
*/
|
||||
getSizeInfo(size: ImageSize) {
|
||||
return IMAGE_SIZE_OPTIONS[size];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os tamanhos disponíveis
|
||||
*
|
||||
* @returns Array com todas as opções de tamanho
|
||||
*/
|
||||
getAllSizes() {
|
||||
return Object.entries(IMAGE_SIZE_OPTIONS).map(([key, info]) => ({
|
||||
value: key as ImageSize,
|
||||
@@ -208,213 +104,98 @@ class ImageGenerationService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida a descrição para geração de imagem
|
||||
*
|
||||
* @param description - Descrição a ser validada
|
||||
* @param minLength - Comprimento mínimo (padrão: 3 caracteres)
|
||||
* @param maxLength - Comprimento máximo (padrão: 1000 caracteres)
|
||||
* @returns Objeto com resultado da validação
|
||||
*/
|
||||
validateDescription(
|
||||
description: string,
|
||||
minLength: number = 3,
|
||||
maxLength: number = 1000
|
||||
): { valid: boolean; error?: string } {
|
||||
if (!description || description.trim().length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'A descrição não pode estar vazia',
|
||||
};
|
||||
}
|
||||
|
||||
if (description.trim().length < minLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `A descrição deve ter pelo menos ${minLength} caracteres`,
|
||||
};
|
||||
}
|
||||
|
||||
if (description.length > maxLength) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `A descrição é muito longa. Máximo: ${maxLength} caracteres`,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz download de uma imagem gerada
|
||||
*
|
||||
* @param imageUrl - URL da imagem
|
||||
* @param filename - Nome do arquivo (opcional)
|
||||
*/
|
||||
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 (error) {
|
||||
console.error('Erro ao baixar imagem:', error);
|
||||
} catch {
|
||||
throw new Error('Não foi possível baixar a imagem');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista imagens do banco de dados com paginação
|
||||
*
|
||||
* @param userEmail - Email do usuário
|
||||
* @param page - Número da página (padrão: 1)
|
||||
* @param perPage - Quantidade de itens por página (padrão: 10)
|
||||
* @returns Promise com a resposta paginada
|
||||
*/
|
||||
async getImages(
|
||||
userEmail?: string,
|
||||
page: number = 1,
|
||||
perPage: number = 10
|
||||
): Promise<ImageRecord[]> {
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
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',
|
||||
};
|
||||
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||
}
|
||||
|
||||
console.log('Buscando imagens:', {
|
||||
userEmail: email,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
|
||||
try {
|
||||
// Faz a requisição GET com parâmetros na URL e query
|
||||
const response = await apiService.get<ImageRecord[]>(
|
||||
const response = await apiService.get<unknown>(
|
||||
`${this.GET_IMAGES_ENDPOINT}/${email}`,
|
||||
{
|
||||
params: {
|
||||
page: page.toString(),
|
||||
per_page: perPage.toString(),
|
||||
},
|
||||
}
|
||||
{ params: { page: page.toString(), per_page: perPage.toString() } }
|
||||
);
|
||||
|
||||
console.log('Resposta completa da API:', response);
|
||||
console.log('response.data:', response.data);
|
||||
console.log('É array?:', Array.isArray(response.data));
|
||||
|
||||
// A API retorna diretamente o array de imagens
|
||||
// Garante que sempre retorna um array
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
// Se a resposta for um objeto com uma propriedade 'images' ou similar
|
||||
console.warn('API retornou objeto em vez de array:', response.data);
|
||||
|
||||
// Tenta encontrar o array dentro do objeto
|
||||
if (Array.isArray((response.data as any).images)) {
|
||||
return (response.data as any).images;
|
||||
} else if (Array.isArray((response.data as any).data)) {
|
||||
return (response.data as any).data;
|
||||
}
|
||||
}
|
||||
|
||||
// Se não conseguir extrair array, retorna vazio
|
||||
console.warn('Não foi possível extrair array de imagens da resposta');
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
return this.extractArray<ImageRecord>(response.data, ['images', 'data']);
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar imagens:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao buscar imagens',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error, 'Erro ao buscar imagens');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleta uma imagem do banco de dados
|
||||
*
|
||||
* @param imageId - ID da imagem a ser deletada
|
||||
* @param userEmail - Email do usuário (opcional)
|
||||
* @returns Promise com sucesso ou erro
|
||||
*/
|
||||
async deleteImage(imageId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Deletando imagem:', {
|
||||
imageId,
|
||||
userEmail: email,
|
||||
});
|
||||
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 {
|
||||
// Faz a requisição DELETE com parâmetros na URL
|
||||
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
|
||||
`${this.DELETE_IMAGE_ENDPOINT}/${email}/${imageId}`
|
||||
);
|
||||
|
||||
console.log('Resposta completa do DELETE:', response);
|
||||
console.log('response.data:', response.data);
|
||||
|
||||
// A API pode retornar um array com um objeto: [{"success":true}]
|
||||
// ou diretamente um objeto: {"success":true}
|
||||
let result: { success: boolean; message?: string };
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
// Se for array, pega o primeiro elemento
|
||||
result = response.data[0];
|
||||
console.log('API retornou array, usando primeiro elemento:', result);
|
||||
} else {
|
||||
// Se for objeto direto
|
||||
result = response.data;
|
||||
console.log('API retornou objeto direto:', result);
|
||||
}
|
||||
|
||||
// Garante que tem a estrutura mínima
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
message: result.message || 'Imagem deletada com sucesso',
|
||||
};
|
||||
} catch (error: any) {
|
||||
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);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao deletar imagem',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error, 'Erro ao deletar imagem');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exporta instância única (Singleton)
|
||||
export const imageGenerationService = new ImageGenerationService();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Exporta todos os serviços de API
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
@@ -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();
|
||||
+75
-281
@@ -1,4 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { GlobalFunctions } from '@/GlobalFunctions';
|
||||
|
||||
export interface UserProfile {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
@@ -37,7 +39,7 @@ export interface ExpenseItem {
|
||||
id: string;
|
||||
data_hora: string;
|
||||
descricao: string;
|
||||
tipo: "pessoal" | "corporativo";
|
||||
tipo: 'pessoal' | 'corporativo';
|
||||
valor: string;
|
||||
categoria_id: number;
|
||||
categoria_nome: string;
|
||||
@@ -71,273 +73,126 @@ export interface ExpenseCategory {
|
||||
atualizado_em: string;
|
||||
}
|
||||
|
||||
type AxiosLikeError = { response?: { data?: unknown; status?: number } };
|
||||
|
||||
class PersonalAgent {
|
||||
|
||||
async getUserProfile(userEmail?: string): Promise<UserProfile> {
|
||||
const email = userEmail || GlobalFunctions.getUsuarioLogado().email;
|
||||
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
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,
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers['apikey'] = apiKey;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
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: any) {
|
||||
if (error.response?.data) {
|
||||
return error.response.data;
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao buscar perfil do usuário',
|
||||
status: error.response?.status,
|
||||
};
|
||||
} 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 || request.nome.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Nome é obrigatório',
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.email || request.email.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email é obrigatório',
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.whatsapp || request.whatsapp.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'WhatsApp é obrigatório',
|
||||
};
|
||||
}
|
||||
|
||||
const whatsappNumbers = request.whatsapp.replace(/\D/g, '');
|
||||
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 axios = (await import('axios')).default;
|
||||
|
||||
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;
|
||||
} else {
|
||||
console.warn('API_KEY não configurada');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
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: whatsappNumbers,
|
||||
whatsapp: request.whatsapp.replace(/\D/g, ''),
|
||||
followup: request.followup,
|
||||
},
|
||||
{ headers }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.data) {
|
||||
throw error.response.data;
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao criar usuário',
|
||||
status: error.response?.status,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.handleError(error, 'Erro ao criar usuário');
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(userId: string, request: UpdateUserRequest): Promise<UserProfile> {
|
||||
|
||||
if (!userId || userId.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'ID do usuário é obrigatório',
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.nome || request.nome.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Nome é obrigatório',
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.whatsapp || request.whatsapp.trim().length === 0) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'WhatsApp é obrigatório',
|
||||
};
|
||||
}
|
||||
|
||||
const whatsappNumbers = request.whatsapp.replace(/\D/g, '');
|
||||
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 axios = (await import('axios')).default;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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: whatsappNumbers,
|
||||
whatsapp: request.whatsapp.replace(/\D/g, ''),
|
||||
followup: request.followup,
|
||||
},
|
||||
{ headers }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.data) {
|
||||
throw error.response.data;
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao atualizar usuário',
|
||||
status: error.response?.status,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.handleError(error, 'Erro ao atualizar usuário');
|
||||
}
|
||||
}
|
||||
|
||||
async getFinancialIndicators(userEmail?: string): Promise<FinancialIndicators> {
|
||||
const email = userEmail || GlobalFunctions.getUsuarioLogado().email;
|
||||
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário é obrigatório',
|
||||
};
|
||||
}
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário é obrigatório' };
|
||||
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: FinancialIndicators } };
|
||||
if (axiosError.response?.data) {
|
||||
throw axiosError.response.data;
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erro ao buscar indicadores financeiros',
|
||||
status: error && typeof error === 'object' && 'response' in error
|
||||
? (error as { response?: { status?: number } }).response?.status
|
||||
: undefined,
|
||||
};
|
||||
this.handleError(error, 'Erro ao buscar indicadores financeiros');
|
||||
}
|
||||
}
|
||||
|
||||
async getExpenses(userEmail?: string, filters?: ExpensesFilters): Promise<ExpensesResponse> {
|
||||
const email = userEmail || GlobalFunctions.getUsuarioLogado().email;
|
||||
const email = this.resolveEmail(userEmail);
|
||||
if (!email) throw { success: false, message: 'Email do usuário é obrigatório' };
|
||||
|
||||
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 axios = (await import('axios')).default;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
// Constrói query params
|
||||
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());
|
||||
@@ -346,96 +201,35 @@ class PersonalAgent {
|
||||
if (filters?.data_inicial) params.append('data_inicial', filters.data_inicial);
|
||||
if (filters?.data_final) params.append('data_final', filters.data_final);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/financas/${email}${queryString ? `?${queryString}` : ''}`;
|
||||
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) {
|
||||
const expensesResponse = response.data[0];
|
||||
if (!Array.isArray(response.data) || response.data.length === 0) return empty;
|
||||
|
||||
// Valida e limpa o array de dados, removendo objetos vazios
|
||||
if (expensesResponse.data && Array.isArray(expensesResponse.data)) {
|
||||
// Filtra objetos vazios (sem propriedades ou apenas com propriedades vazias)
|
||||
expensesResponse.data = expensesResponse.data.filter((item) => {
|
||||
// Verifica se o objeto tem pelo menos uma propriedade válida
|
||||
return item && typeof item === 'object' && Object.keys(item).length > 0 && item.id;
|
||||
});
|
||||
|
||||
// Se após filtrar não há dados, garante que data seja um array vazio
|
||||
if (expensesResponse.data.length === 0) {
|
||||
expensesResponse.data = [];
|
||||
expensesResponse.total_registros = 0;
|
||||
expensesResponse.total_paginas = 0;
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
// Se data não é um array válido, inicializa como array vazio
|
||||
expensesResponse.data = [];
|
||||
expensesResponse.total_registros = 0;
|
||||
expensesResponse.total_paginas = 0;
|
||||
}
|
||||
|
||||
return expensesResponse;
|
||||
}
|
||||
|
||||
// Fallback caso a estrutura seja diferente - retorna resposta vazia
|
||||
return {
|
||||
success: true,
|
||||
total_registros: 0,
|
||||
total_paginas: 0,
|
||||
per_page: filters?.per_page || 10,
|
||||
pagina_atual: filters?.page || 1,
|
||||
data: [],
|
||||
};
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
// Retorna o erro da API se existir
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: ExpensesResponse } };
|
||||
if (axiosError.response?.data) {
|
||||
throw axiosError.response.data;
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Erro ao buscar despesas',
|
||||
status: error && typeof error === 'object' && 'response' in error
|
||||
? (error as { response?: { status?: number } }).response?.status
|
||||
: undefined,
|
||||
};
|
||||
this.handleError(error, 'Erro ao buscar despesas');
|
||||
}
|
||||
}
|
||||
|
||||
async getCategories(): Promise<ExpenseCategory[]> {
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
const response = await axios.get<ExpenseCategory[]>('https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/categorias', { headers });
|
||||
|
||||
// A API retorna um array de categorias
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { apiService } from "./api";
|
||||
|
||||
export interface PromptItem {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
conteudo: string;
|
||||
area_id: string;
|
||||
area_nome: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListarPromptsParams {
|
||||
titulo?: string;
|
||||
area_id?: string;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export interface ListarPromptsResponseBody {
|
||||
success: true;
|
||||
total_registros: number;
|
||||
total_paginas: number;
|
||||
per_page: number;
|
||||
pagina_atual: number;
|
||||
data: PromptItem[];
|
||||
}
|
||||
|
||||
export interface ListarPromptsPorAreaResponse {
|
||||
success: boolean;
|
||||
quantidade?: number;
|
||||
data: PromptItem[];
|
||||
}
|
||||
|
||||
export interface CriarPromptBody {
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
area_id: string;
|
||||
conteudo: string;
|
||||
}
|
||||
|
||||
export interface CriarPromptSuccessResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
conteudo: string;
|
||||
}
|
||||
|
||||
export interface CriarPromptErrorResponse {
|
||||
success: false;
|
||||
missing_fields?: string[];
|
||||
}
|
||||
|
||||
export interface EditarPromptBody {
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
area_id: string;
|
||||
conteudo: string;
|
||||
}
|
||||
|
||||
export interface EditarPromptSuccessResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
area_id: string;
|
||||
conteudo: string;
|
||||
}
|
||||
|
||||
export interface EditarPromptErrorResponse {
|
||||
success: false;
|
||||
missing_fields?: string[];
|
||||
}
|
||||
|
||||
export interface DeletarPromptSuccessResponse {
|
||||
success: true;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DeletarPromptErrorResponse {
|
||||
success: false;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class PromptsService {
|
||||
async listar(params: ListarPromptsParams): Promise<ListarPromptsResponseBody> {
|
||||
const { page, per_page, titulo, area_id } = params;
|
||||
|
||||
const response = await apiService.get<ListarPromptsResponseBody[]>(
|
||||
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/prompts",
|
||||
{
|
||||
params: {
|
||||
...(titulo != null && titulo.trim() !== "" ? { titulo: titulo.trim() } : {}),
|
||||
...(area_id != null && area_id.trim() !== "" ? { area_id: area_id.trim() } : {}),
|
||||
page,
|
||||
per_page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const first = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
|
||||
if (!first || first.success !== true) {
|
||||
throw new Error("Resposta inválida ao listar prompts");
|
||||
}
|
||||
|
||||
return first;
|
||||
}
|
||||
|
||||
async listarPorArea(areaId: string): Promise<ListarPromptsPorAreaResponse> {
|
||||
if (!areaId?.trim()) {
|
||||
return { success: true, quantidade: 0, data: [] };
|
||||
}
|
||||
|
||||
const areaIdEnc = encodeURIComponent(areaId.trim());
|
||||
|
||||
const response = await apiService.get<ListarPromptsPorAreaResponse | ListarPromptsPorAreaResponse[]>(
|
||||
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/prompts/listar-todos-area?area_id=${areaIdEnc}`
|
||||
);
|
||||
|
||||
const first = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
|
||||
if (!first || first.success !== true) {
|
||||
throw new Error("Resposta inválida ao listar prompts da área");
|
||||
}
|
||||
|
||||
const list = first.data ?? [];
|
||||
return { success: true, quantidade: first.quantidade ?? list.length, data: list };
|
||||
}
|
||||
|
||||
async criar(body: CriarPromptBody): Promise<CriarPromptSuccessResponse> {
|
||||
if (!body.titulo?.trim() || !body.area_id?.trim() || !body.conteudo?.trim()) {
|
||||
throw new Error("Título, área e conteúdo são obrigatórios");
|
||||
}
|
||||
|
||||
const response = await apiService.post<CriarPromptSuccessResponse | CriarPromptErrorResponse>(
|
||||
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/prompt/criar",
|
||||
{
|
||||
titulo: body.titulo.trim(),
|
||||
descricao: (body.descricao ?? "").trim(),
|
||||
area_id: body.area_id.trim(),
|
||||
conteudo: body.conteudo.trim(),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success === false) {
|
||||
const err = response.data as CriarPromptErrorResponse;
|
||||
const msg = err.missing_fields?.length
|
||||
? `Preencha: ${err.missing_fields.join(", ")}`
|
||||
: "Erro ao criar prompt.";
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return response.data as CriarPromptSuccessResponse;
|
||||
}
|
||||
|
||||
async editar(id: string, body: EditarPromptBody): Promise<EditarPromptSuccessResponse> {
|
||||
if (!id?.trim()) {
|
||||
throw new Error("ID do prompt é obrigatório");
|
||||
}
|
||||
|
||||
const idEnc = encodeURIComponent(id.trim());
|
||||
/** Mesmo prefixo de webhook n8n usado em edição de área (`areas.editar`). */
|
||||
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/parecer/prompt/${idEnc}`;
|
||||
|
||||
const response = await apiService.put<
|
||||
EditarPromptSuccessResponse | EditarPromptErrorResponse | (EditarPromptSuccessResponse | EditarPromptErrorResponse)[]
|
||||
>(url, {
|
||||
titulo: (body.titulo ?? "").trim(),
|
||||
descricao: (body.descricao ?? "").trim(),
|
||||
area_id: body.area_id.trim(),
|
||||
conteudo: (body.conteudo ?? "").trim(),
|
||||
});
|
||||
|
||||
const raw = response.data;
|
||||
const data = Array.isArray(raw) ? raw[0] : raw;
|
||||
|
||||
if (!data || typeof data !== "object" || !("success" in data)) {
|
||||
throw new Error("Resposta inválida ao editar prompt");
|
||||
}
|
||||
|
||||
if (data.success === false) {
|
||||
const err = data as EditarPromptErrorResponse;
|
||||
const msg = err.missing_fields?.length
|
||||
? `Preencha: ${err.missing_fields.join(", ")}`
|
||||
: "Erro ao editar prompt.";
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return data as EditarPromptSuccessResponse;
|
||||
}
|
||||
|
||||
async deletar(id: string): Promise<DeletarPromptSuccessResponse> {
|
||||
if (!id?.trim()) {
|
||||
throw new Error("ID do prompt é obrigatório");
|
||||
}
|
||||
|
||||
const idEnc = encodeURIComponent(id.trim());
|
||||
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/4e6c2374-9c22-4c81-b558-45f0cfefa5c3/codex/parecer/prompt/deletar/${idEnc}`;
|
||||
|
||||
const response = await apiService.delete<
|
||||
DeletarPromptSuccessResponse | DeletarPromptErrorResponse | (DeletarPromptSuccessResponse | DeletarPromptErrorResponse)[]
|
||||
>(url);
|
||||
|
||||
const raw = response.data;
|
||||
const data = Array.isArray(raw) ? raw[0] : raw;
|
||||
|
||||
if (!data || typeof data !== "object" || !("success" in data)) {
|
||||
throw new Error("Resposta inválida ao excluir prompt");
|
||||
}
|
||||
|
||||
if (data.success === false) {
|
||||
const err = data as DeletarPromptErrorResponse;
|
||||
throw new Error(err.message ?? "Erro ao excluir prompt.");
|
||||
}
|
||||
|
||||
return data as DeletarPromptSuccessResponse;
|
||||
}
|
||||
}
|
||||
|
||||
export const promptsService = new PromptsService();
|
||||
+58
-202
@@ -1,9 +1,6 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
|
||||
/**
|
||||
* Interface para a resposta da API de transcrição
|
||||
*/
|
||||
export interface TranscriptionResponse {
|
||||
success: boolean;
|
||||
transcription_id: string;
|
||||
@@ -11,9 +8,6 @@ export interface TranscriptionResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para uma transcrição armazenada no banco de dados
|
||||
*/
|
||||
export interface TranscriptionRecord {
|
||||
id: string;
|
||||
user_email: string;
|
||||
@@ -27,246 +21,108 @@ export interface TranscriptionRecord {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para os dados necessários para transcrição
|
||||
*/
|
||||
export interface TranscriptionRequest {
|
||||
audioFile: File;
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serviço de transcrição de áudio
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* Transcreve um arquivo de áudio
|
||||
*
|
||||
* @param request - Dados da requisição (arquivo, email, estabelecimento)
|
||||
* @returns Promise com a resposta da API
|
||||
*/
|
||||
async transcribeAudio(request: TranscriptionRequest): Promise<TranscriptionResponse> {
|
||||
const { audioFile, userEmail, estabelecimentoId } = request;
|
||||
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);
|
||||
|
||||
// Cria FormData para envio multipart
|
||||
const formData = new FormData();
|
||||
formData.append('data', audioFile);
|
||||
|
||||
// Usa valores do .env se não forem fornecidos
|
||||
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
||||
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
|
||||
|
||||
formData.append('user_email', email);
|
||||
formData.append('estabelecimento_id', estabId.toString());
|
||||
|
||||
// Log para debug (remover em produção se necessário)
|
||||
console.log('Enviando transcrição:', {
|
||||
fileName: audioFile.name,
|
||||
fileSize: audioFile.size,
|
||||
fileType: audioFile.type,
|
||||
userEmail: email,
|
||||
estabelecimentoId: estabId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Faz a requisição usando o serviço de API
|
||||
const response = await apiService.postFormData<TranscriptionResponse>(
|
||||
this.TRANSCRIPTION_ENDPOINT,
|
||||
formData
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Trata erros específicos
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro na transcrição:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao transcrever áudio',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error, 'Erro ao transcrever áudio');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida se o arquivo de áudio é suportado
|
||||
*
|
||||
* @param file - Arquivo a ser validado
|
||||
* @param maxSizeMB - Tamanho máximo em MB (padrão: 25MB)
|
||||
* @returns Objeto com resultado da validação
|
||||
*/
|
||||
validateAudioFile(file: File, maxSizeMB: number = 25): { valid: boolean; error?: string } {
|
||||
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
|
||||
const MAX_FILE_SIZE = maxSizeMB * 1024 * 1024;
|
||||
|
||||
// Valida tamanho
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`,
|
||||
};
|
||||
}
|
||||
|
||||
// Valida formato
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!fileExtension || !SUPPORTED_FORMATS.includes(fileExtension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Formato não suportado. Formatos aceitos: ${SUPPORTED_FORMATS.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista transcrições do banco de dados com paginação
|
||||
*
|
||||
* @param userEmail - Email do usuário
|
||||
* @param page - Número da página (padrão: 1)
|
||||
* @param perPage - Quantidade de itens por página (padrão: 10)
|
||||
* @returns Promise com o array de transcrições
|
||||
*/
|
||||
async getTranscriptions(
|
||||
userEmail?: string,
|
||||
page: number = 1,
|
||||
perPage: number = 10
|
||||
): Promise<TranscriptionRecord[]> {
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Buscando transcrições:', {
|
||||
userEmail: email,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
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 {
|
||||
// Faz a requisição GET com parâmetros na URL e query
|
||||
const response = await apiService.get<TranscriptionRecord[]>(
|
||||
const response = await apiService.get<unknown>(
|
||||
`${this.GET_TRANSCRIPTIONS_ENDPOINT}/${email}`,
|
||||
{
|
||||
params: {
|
||||
page: page.toString(),
|
||||
per_page: perPage.toString(),
|
||||
},
|
||||
}
|
||||
{ params: { page: page.toString(), per_page: perPage.toString() } }
|
||||
);
|
||||
|
||||
console.log('Resposta completa da API:', response);
|
||||
console.log('response.data:', response.data);
|
||||
console.log('É array?:', Array.isArray(response.data));
|
||||
|
||||
// A API retorna diretamente o array de transcrições
|
||||
// Garante que sempre retorna um array
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
// Se a resposta for um objeto com uma propriedade 'transcriptions' ou similar
|
||||
console.warn('API retornou objeto em vez de array:', response.data);
|
||||
|
||||
// Tenta encontrar o array dentro do objeto
|
||||
if (Array.isArray((response.data as any).transcriptions)) {
|
||||
return (response.data as any).transcriptions;
|
||||
} else if (Array.isArray((response.data as any).data)) {
|
||||
return (response.data as any).data;
|
||||
}
|
||||
}
|
||||
|
||||
// Se não conseguir extrair array, retorna vazio
|
||||
console.warn('Não foi possível extrair array de transcrições da resposta');
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
return this.extractArray<TranscriptionRecord>(response.data, ['transcriptions', 'data']);
|
||||
} catch (error: unknown) {
|
||||
console.error('Erro ao buscar transcrições:', error);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao buscar transcrições',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error, 'Erro ao buscar transcrições');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleta uma transcrição do banco de dados
|
||||
*
|
||||
* @param transcriptionId - ID da transcrição a ser deletada
|
||||
* @param userEmail - Email do usuário (opcional)
|
||||
* @returns Promise com sucesso ou erro
|
||||
*/
|
||||
async deleteTranscription(transcriptionId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
|
||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
|
||||
if (!email) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'Email do usuário não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
if (!transcriptionId) {
|
||||
throw {
|
||||
success: false,
|
||||
message: 'ID da transcrição não fornecido',
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Deletando transcrição:', {
|
||||
transcriptionId,
|
||||
userEmail: email,
|
||||
});
|
||||
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 {
|
||||
// Faz a requisição DELETE com parâmetros na URL
|
||||
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
|
||||
`${this.DELETE_TRANSCRIPTION_ENDPOINT}/${email}/${transcriptionId}`
|
||||
);
|
||||
|
||||
console.log('Resposta completa do DELETE:', response);
|
||||
console.log('response.data:', response.data);
|
||||
|
||||
// A API retorna um array com um objeto: [{"success":true}]
|
||||
let result: { success: boolean; message?: string };
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
// Se for array, pega o primeiro elemento
|
||||
result = response.data[0];
|
||||
console.log('API retornou array, usando primeiro elemento:', result);
|
||||
} else {
|
||||
// Se for objeto direto
|
||||
result = response.data;
|
||||
console.log('API retornou objeto direto:', result);
|
||||
}
|
||||
|
||||
// Garante que tem a estrutura mínima
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
message: result.message || 'Transcrição deletada com sucesso',
|
||||
};
|
||||
} catch (error: any) {
|
||||
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);
|
||||
|
||||
throw {
|
||||
success: false,
|
||||
message: error.message || 'Erro ao deletar transcrição',
|
||||
status: error.status,
|
||||
};
|
||||
this.toApiError(error, 'Erro ao deletar transcrição');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exporta instância única (Singleton)
|
||||
export const transcriptionService = new TranscriptionService();
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
/**
|
||||
* Tipos compartilhados para as APIs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resposta padrão de sucesso/erro da API
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuração de usuário para requisições
|
||||
*/
|
||||
export interface UserConfig {
|
||||
userEmail?: string;
|
||||
estabelecimentoId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resposta de erro da API
|
||||
*/
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
message: string;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -111,5 +111,5 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
} satisfies Config;
|
||||
|
||||
Reference in New Issue
Block a user