Ajustes e melhorias no Codex

This commit is contained in:
Vitex Tecnologia
2026-03-23 18:24:08 -03:00
parent 8d8818c32e
commit 8f2e4f1d21
7 changed files with 375 additions and 62 deletions
+33 -4
View File
@@ -38,9 +38,21 @@ export function AreasView() {
const [totalRegistros, setTotalRegistros] = useState(0); const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1); const [totalPaginas, setTotalPaginas] = useState(1);
const [loadingList, setLoadingList] = useState(true); const [loadingList, setLoadingList] = useState(true);
const normalizedAreas = useMemo(
() => new Set(areas.map((area) => area.trim().toLocaleLowerCase())),
[areas]
);
const totalPages = Math.max(1, totalPaginas); const totalPages = Math.max(1, totalPaginas);
const isFiltering = filterNome.trim().length > 0; 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -90,7 +102,7 @@ export function AreasView() {
const name = newName.trim(); const name = newName.trim();
const description = newDescription.trim(); const description = newDescription.trim();
if (!name) return; if (!name) return;
if (areas.includes(name)) { if (normalizedAreas.has(name.toLocaleLowerCase())) {
toast.error("Já existe uma área com esse nome."); toast.error("Já existe uma área com esse nome.");
return; return;
} }
@@ -146,7 +158,10 @@ export function AreasView() {
} }
const name = editName.trim(); const name = editName.trim();
const description = editDescription.trim(); const description = editDescription.trim();
if (name !== selectedAreaItem.nome && areas.includes(name)) { if (
name.toLocaleLowerCase() !== selectedAreaItem.nome.trim().toLocaleLowerCase() &&
normalizedAreas.has(name.toLocaleLowerCase())
) {
toast.error("Já existe uma área com esse nome."); toast.error("Já existe uma área com esse nome.");
return; return;
} }
@@ -412,7 +427,14 @@ export function AreasView() {
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="Ex: Suporte" placeholder="Ex: Suporte"
onKeyDown={(e) => e.key === "Enter" && handleCreate()} onKeyDown={(e) => e.key === "Enter" && handleCreate()}
aria-invalid={isCreateNameDuplicated}
className={isCreateNameDuplicated ? "border-destructive focus-visible:ring-destructive" : undefined}
/> />
{isCreateNameDuplicated && (
<p className="mt-1 text-sm text-destructive">
existe uma área com esse nome. Escolha um nome diferente.
</p>
)}
</div> </div>
<div> <div>
<Label htmlFor="new-area-desc">Descrição</Label> <Label htmlFor="new-area-desc">Descrição</Label>
@@ -430,7 +452,7 @@ export function AreasView() {
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreatingArea}> <Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreatingArea}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleCreate} disabled={!newName.trim() || areas.includes(newName.trim()) || isCreatingArea}> <Button onClick={handleCreate} disabled={!newName.trim() || isCreateNameDuplicated || isCreatingArea}>
{isCreatingArea ? "Criando..." : "Criar"} {isCreatingArea ? "Criando..." : "Criar"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -452,7 +474,14 @@ export function AreasView() {
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSaveEdit()} onKeyDown={(e) => e.key === "Enter" && handleSaveEdit()}
aria-invalid={isEditNameDuplicated}
className={isEditNameDuplicated ? "border-destructive focus-visible:ring-destructive" : undefined}
/> />
{isEditNameDuplicated && (
<p className="mt-1 text-sm text-destructive">
existe uma área com esse nome. Escolha um nome diferente.
</p>
)}
</div> </div>
<div> <div>
<Label htmlFor="edit-area-desc">Descrição</Label> <Label htmlFor="edit-area-desc">Descrição</Label>
@@ -470,7 +499,7 @@ export function AreasView() {
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isSavingEdit}> <Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isSavingEdit}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleSaveEdit} disabled={!editName.trim() || isSavingEdit}> <Button onClick={handleSaveEdit} disabled={!editName.trim() || isEditNameDuplicated || isSavingEdit}>
{isSavingEdit ? "Salvando..." : "Salvar"} {isSavingEdit ? "Salvando..." : "Salvar"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -56,6 +56,20 @@ function formatarDataDetalhe(iso: string): string {
} }
} }
/** Evita exibir expressões n8n ($('...')) como nome de usuário. */
function textoAtualizadoPorValido(val: string | null | undefined): string | null {
const v = val?.trim();
if (!v) return null;
if (v.includes("$(") || v.includes("{{") || v.startsWith("$")) return null;
return v;
}
function textoUsuarioParecer(p: ParecerDetalhe): string {
const email = p.user_email?.trim();
const criado = p.criado_por?.trim();
return email || criado || "—";
}
interface ParecerPreviewState { interface ParecerPreviewState {
titulo?: string; titulo?: string;
conteudoMarkdown?: string; conteudoMarkdown?: string;
@@ -83,7 +97,7 @@ export function ParecerJuridicoDetailView() {
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const { areaItems } = usePrompts(); const { areaItems, refreshAreas } = usePrompts();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editTitulo, setEditTitulo] = useState(""); const [editTitulo, setEditTitulo] = useState("");
@@ -107,6 +121,25 @@ export function ParecerJuridicoDetailView() {
const dragStartHeight = useRef(0); const dragStartHeight = useRef(0);
const chatEndRef = useRef<HTMLDivElement>(null); const chatEndRef = useRef<HTMLDivElement>(null);
/** Preenche título e área com os dados atuais do parecer (sempre que abrir o modal de edição). */
const aplicarDadosAtuaisNoFormularioEdicao = useCallback(() => {
if (!parecer) return;
setEditTitulo(parecer.titulo?.trim() ?? "");
setEditAreaId(parecer.area_id?.trim() ?? "");
}, [parecer]);
const openEditModal = useCallback(() => {
aplicarDadosAtuaisNoFormularioEdicao();
setEditOpen(true);
}, [aplicarDadosAtuaisNoFormularioEdicao]);
/** Fecha detalhes e abre edição já com título e área atuais. */
const openEditFromDetailsModal = useCallback(() => {
aplicarDadosAtuaisNoFormularioEdicao();
setDetailsOpen(false);
setEditOpen(true);
}, [aplicarDadosAtuaisNoFormularioEdicao]);
const MIN_INPUT_HEIGHT = 60; const MIN_INPUT_HEIGHT = 60;
const MAX_INPUT_HEIGHT = 400; const MAX_INPUT_HEIGHT = 400;
@@ -135,6 +168,10 @@ export function ParecerJuridicoDetailView() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
const carregarChat = useCallback( const carregarChat = useCallback(
async (silent = false) => { async (silent = false) => {
if (!id?.trim()) return; if (!id?.trim()) return;
@@ -255,11 +292,29 @@ export function ParecerJuridicoDetailView() {
} }
}; };
const abrirModalEdicao = () => { const handleDownloadAnexo = async () => {
if (!parecer) return; if (!parecer?.anexo_url) return;
setEditTitulo(parecer.titulo ?? ""); try {
setEditAreaId(parecer.area_id ?? ""); const response = await fetch(parecer.anexo_url);
setEditOpen(true); if (!response.ok) {
throw new Error(`Erro ${response.status} ao baixar anexo.`);
}
const blob = await response.blob();
const fallbackName = parecer.anexo_nome?.trim() || "anexo";
const disposition = response.headers.get("Content-Disposition");
const match = disposition?.match(/filename\*?=(?:UTF-8'')?"?([^";\n]+)"?/i) || disposition?.match(/filename="?([^";\n]+)"?/i);
const fileName = match ? decodeURIComponent(match[1].trim()) : fallbackName;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Erro ao baixar anexo.");
}
}; };
const handleSalvarEdicao = async () => { const handleSalvarEdicao = async () => {
@@ -441,7 +496,7 @@ export function ParecerJuridicoDetailView() {
size="sm" size="sm"
className="gap-2" className="gap-2"
disabled={!parecer || !id || editSaving} disabled={!parecer || !id || editSaving}
onClick={abrirModalEdicao} onClick={openEditModal}
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
Editar Editar
@@ -487,14 +542,14 @@ export function ParecerJuridicoDetailView() {
</header> </header>
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}> <Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent className="max-w-4xl w-[calc(100vw-2rem)] max-h-[90vh] flex flex-col gap-0 p-0 sm:max-w-4xl"> <DialogContent className="max-w-4xl w-[calc(100vw-2rem)] max-h-[90vh] flex flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="px-6 pt-6 pb-2"> <DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-12">
<DialogTitle>Detalhes do parecer</DialogTitle> <DialogTitle>Detalhes do parecer</DialogTitle>
<DialogDescription className="sr-only">Resumo do parecer</DialogDescription> <DialogDescription className="sr-only">Resumo do parecer</DialogDescription>
</DialogHeader> </DialogHeader>
{parecer && ( {parecer && (
<ScrollArea className="max-h-[min(70vh,640px)] px-6 pb-6"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6">
<div className="space-y-5 text-sm pr-4"> <div className="space-y-5 text-sm pr-1">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label className="text-muted-foreground text-xs">Título</Label> <Label className="text-muted-foreground text-xs">Título</Label>
@@ -507,13 +562,21 @@ export function ParecerJuridicoDetailView() {
<div> <div>
<Label className="text-muted-foreground text-xs">Área</Label> <Label className="text-muted-foreground text-xs">Área</Label>
<p className="mt-1"> <p className="mt-1">
{areaItems.find((a) => a.id === parecer.area_id)?.nome ?? "—"} {parecer.area_nome?.trim() ||
areaItems.find((a) => a.id === parecer.area_id)?.nome ||
"—"}
</p> </p>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label className="text-muted-foreground text-xs">Usuário</Label> <Label className="text-muted-foreground text-xs">Usuário</Label>
<p className="mt-1 break-all">{parecer.user_email || parecer.criado_por || "—"}</p> <p className="mt-1 break-all">{textoUsuarioParecer(parecer)}</p>
</div> </div>
{textoAtualizadoPorValido(parecer.atualizado_por) && (
<div className="sm:col-span-2">
<Label className="text-muted-foreground text-xs">Atualizado por</Label>
<p className="mt-1 break-all">{textoAtualizadoPorValido(parecer.atualizado_por)}</p>
</div>
)}
<div> <div>
<Label className="text-muted-foreground text-xs">Criado em</Label> <Label className="text-muted-foreground text-xs">Criado em</Label>
<p className="mt-1">{formatarDataDetalhe(parecer.created_at)}</p> <p className="mt-1">{formatarDataDetalhe(parecer.created_at)}</p>
@@ -522,6 +585,18 @@ export function ParecerJuridicoDetailView() {
<Label className="text-muted-foreground text-xs">Atualizado em</Label> <Label className="text-muted-foreground text-xs">Atualizado em</Label>
<p className="mt-1">{formatarDataDetalhe(parecer.updated_at)}</p> <p className="mt-1">{formatarDataDetalhe(parecer.updated_at)}</p>
</div> </div>
<div className="sm:col-span-2">
<Label htmlFor="detalhes-instrucao" className="text-muted-foreground text-xs">
Instrução enviada
</Label>
<Textarea
id="detalhes-instrucao"
readOnly
value={parecer.instrucao?.trim() ? parecer.instrucao : "—"}
className="mt-2 min-h-[120px] resize-y whitespace-pre-wrap text-sm leading-relaxed bg-muted/40 cursor-text"
aria-readonly="true"
/>
</div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label className="text-muted-foreground text-xs">Anexo</Label> <Label className="text-muted-foreground text-xs">Anexo</Label>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4"> <div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
@@ -529,30 +604,59 @@ export function ParecerJuridicoDetailView() {
{parecer.anexo_nome ?? (parecer.anexo_url ? "Arquivo anexado" : "—")} {parecer.anexo_nome ?? (parecer.anexo_url ? "Arquivo anexado" : "—")}
</p> </p>
{parecer.anexo_url ? ( {parecer.anexo_url ? (
<Button type="button" variant="outline" size="sm" className="w-fit shrink-0 gap-2" asChild> <Button
<a href={parecer.anexo_url} target="_blank" rel="noreferrer" download> type="button"
variant="outline"
size="sm"
className="w-fit shrink-0 gap-2"
onClick={() => void handleDownloadAnexo()}
>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Download Download
</a>
</Button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<Label className="text-muted-foreground text-xs">Prompt utilizado</Label> <Label htmlFor="detalhes-prompt" className="text-muted-foreground text-xs">
<div className="mt-2 rounded-md border bg-muted/40 p-4 max-h-56 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed"> Prompt utilizado
{parecer.prompt_conteudo || "—"} </Label>
<Textarea
id="detalhes-prompt"
readOnly
value={parecer.prompt_conteudo?.trim() ? parecer.prompt_conteudo : "—"}
className="mt-2 min-h-[160px] resize-y whitespace-pre-wrap text-sm leading-relaxed bg-muted/40 cursor-text"
aria-readonly="true"
/>
</div> </div>
</div> </div>
</div> </div>
</ScrollArea> )}
{parecer && (
<DialogFooter className="shrink-0 border-t px-6 py-4 sm:justify-between gap-2">
<Button type="button" variant="outline" onClick={() => setDetailsOpen(false)}>
Fechar
</Button>
<Button type="button" className="gap-2" onClick={openEditFromDetailsModal}>
<Pencil className="w-4 h-4" />
Editar parecer
</Button>
</DialogFooter>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={editOpen} onOpenChange={setEditOpen}> <Dialog
<DialogContent className="max-w-md"> open={editOpen}
onOpenChange={(open) => {
setEditOpen(open);
if (open) {
aplicarDadosAtuaisNoFormularioEdicao();
}
}}
>
<DialogContent className="max-w-md" key={editOpen && parecer ? `edit-${parecer.id}` : "edit-closed"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Editar título e área</DialogTitle> <DialogTitle>Editar título e área</DialogTitle>
<DialogDescription>Atualize os campos obrigatórios do parecer.</DialogDescription> <DialogDescription>Atualize os campos obrigatórios do parecer.</DialogDescription>
@@ -585,12 +689,23 @@ export function ParecerJuridicoDetailView() {
</span> </span>
<span className="sr-only">obrigatório</span> <span className="sr-only">obrigatório</span>
</Label> </Label>
<Select value={editAreaId || "none"} onValueChange={(v) => setEditAreaId(v === "none" ? "" : v)}> <Select
value={editAreaId || "none"}
onValueChange={(v) => setEditAreaId(v === "none" ? "" : v)}
key={`area-select-${parecer?.id}-${editAreaId}`}
>
<SelectTrigger id="edit-area"> <SelectTrigger id="edit-area">
<SelectValue placeholder="Selecione a área" /> <SelectValue placeholder="Selecione a área" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">Selecione a área</SelectItem> <SelectItem value="none">Selecione a área</SelectItem>
{parecer?.area_id &&
!areaItems.some((a) => a.id === parecer.area_id) &&
parecer.area_id.trim() !== "" && (
<SelectItem value={parecer.area_id}>
{parecer.area_nome?.trim() || `Área (${parecer.area_id.slice(0, 8)}…)`}
</SelectItem>
)}
{areaItems.map((a) => ( {areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}> <SelectItem key={a.id} value={a.id}>
{a.nome} {a.nome}
@@ -716,10 +831,10 @@ export function ParecerJuridicoDetailView() {
</Dialog> </Dialog>
<div className="flex-1 flex flex-col md:flex-row min-h-0"> <div className="flex-1 flex flex-col md:flex-row min-h-0">
<section className="flex-1 min-w-0 flex flex-col border-b md:border-b-0 md:border-r border-border bg-white"> <section className="flex-1 min-w-0 flex flex-col border-b md:border-b-0 md:border-r border-border bg-background">
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-4 md:p-6 bg-white min-h-full"> <div className="p-4 md:p-6 bg-background min-h-full">
<article className="prose prose-sm prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-p:leading-relaxed prose-ul:my-3 prose-ol:my-3 prose-li:my-0.5"> <article className="prose prose-sm prose-neutral dark:prose-invert max-w-none text-foreground prose-headings:font-semibold prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-p:leading-relaxed prose-ul:my-3 prose-ol:my-3 prose-li:my-0.5">
{conteudoMarkdown.trim() ? ( {conteudoMarkdown.trim() ? (
<ReactMarkdown>{conteudoMarkdown}</ReactMarkdown> <ReactMarkdown>{conteudoMarkdown}</ReactMarkdown>
) : ( ) : (
@@ -43,7 +43,7 @@ function statusEstilo(status: string): { dot: string; text: string; label: strin
export const ParecerJuridicoView = () => { export const ParecerJuridicoView = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { areaItems } = usePrompts(); const { areaItems, refreshAreas } = usePrompts();
const [data, setData] = useState<ParecerListItem[]>([]); const [data, setData] = useState<ParecerListItem[]>([]);
const [totalRegistros, setTotalRegistros] = useState(0); const [totalRegistros, setTotalRegistros] = useState(0);
@@ -95,6 +95,10 @@ export const ParecerJuridicoView = () => {
loadList(); loadList();
}, [loadList]); }, [loadList]);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
const handleItemsPerPageChange = (value: string) => { const handleItemsPerPageChange = (value: string) => {
setPerPage(Number(value)); setPerPage(Number(value));
setPaginaAtual(1); setPaginaAtual(1);
+69 -2
View File
@@ -1,12 +1,13 @@
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FileText, Plus, Eye, Edit, Copy } from "lucide-react"; import { FileText, Plus, Eye, Edit, Copy, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext"; import { usePrompts } from "@/contexts/PromptsContext";
import type { Prompt } from "@/contexts/PromptsContext"; import type { Prompt } from "@/contexts/PromptsContext";
@@ -26,10 +27,12 @@ function apiItemToPrompt(item: { id: string; titulo: string; area_nome: string;
export const PromptsView = () => { export const PromptsView = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { prompts, setPrompts, areaItems } = usePrompts(); const { prompts, setPrompts, areaItems, refreshAreas } = usePrompts();
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false); const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null); const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [promptToDelete, setPromptToDelete] = useState<Prompt | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -39,6 +42,7 @@ export const PromptsView = () => {
const [totalRegistros, setTotalRegistros] = useState(0); const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1); const [totalPaginas, setTotalPaginas] = useState(1);
const [loadingList, setLoadingList] = useState(true); const [loadingList, setLoadingList] = useState(true);
const [isDeletingPrompt, setIsDeletingPrompt] = useState(false);
const totalPages = Math.max(1, totalPaginas); const totalPages = Math.max(1, totalPaginas);
@@ -88,6 +92,10 @@ export const PromptsView = () => {
setCurrentPage(1); setCurrentPage(1);
}, [filterNome, filterAreaId]); }, [filterNome, filterAreaId]);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
const clearFilters = () => { const clearFilters = () => {
setFilterNome(""); setFilterNome("");
setFilterAreaId(""); setFilterAreaId("");
@@ -112,6 +120,35 @@ export const PromptsView = () => {
navigate(`/codex/prompts/${selectedPrompt.id}`); 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 ( return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0"> <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="p-3 md:p-6 border-b border-border">
@@ -240,6 +277,15 @@ export const PromptsView = () => {
> >
<Edit className="w-3 h-3 md:w-4 md:h-4" /> <Edit className="w-3 h-3 md:w-4 md:h-4" />
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -365,6 +411,27 @@ export const PromptsView = () => {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
}; };
+15 -10
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect } from "react"; import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { areasService, type AreaItem } from "@/services/areas"; import { areasService, type AreaItem } from "@/services/areas";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -35,6 +35,7 @@ interface PromptsContextValue {
areaIdByNome: AreaIdByNome; areaIdByNome: AreaIdByNome;
areasLoading: boolean; areasLoading: boolean;
areasError: string | null; areasError: string | null;
refreshAreas: () => Promise<void>;
} }
const PromptsContext = createContext<PromptsContextValue | null>(null); const PromptsContext = createContext<PromptsContextValue | null>(null);
@@ -48,11 +49,11 @@ export function PromptsProvider({ children }: { children: React.ReactNode }) {
const [areasLoading, setAreasLoading] = useState(true); const [areasLoading, setAreasLoading] = useState(true);
const [areasError, setAreasError] = useState<string | null>(null); const [areasError, setAreasError] = useState<string | null>(null);
useEffect(() => { const refreshAreas = useCallback(async () => {
setAreasLoading(true); setAreasLoading(true);
setAreasError(null); setAreasError(null);
areasService.listarTotal() try {
.then((data) => { const data = await areasService.listarTotal();
const nomes = data.map((a) => a.nome).sort((a, b) => a.localeCompare(b)); 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 descricoes = Object.fromEntries(data.map((a) => [a.nome, a.descricao ?? ""]));
const byNome = Object.fromEntries(data.map((a) => [a.nome, a.id])); const byNome = Object.fromEntries(data.map((a) => [a.nome, a.id]));
@@ -60,18 +61,22 @@ export function PromptsProvider({ children }: { children: React.ReactNode }) {
setAreaDescriptions(descricoes); setAreaDescriptions(descricoes);
setAreaItems(data); setAreaItems(data);
setAreaIdByNome(byNome); setAreaIdByNome(byNome);
}) } catch (err) {
.catch((err) => { const message = (err as { message?: string })?.message ?? "Erro ao carregar áreas";
const message = err?.message ?? "Erro ao carregar áreas";
console.error("[PromptsContext] areasService.listarTotal falhou:", err); console.error("[PromptsContext] areasService.listarTotal falhou:", err);
setAreasError(message); setAreasError(message);
toast.error("Não foi possível carregar as áreas. Verifique a conexão e a chave da API."); toast.error("Não foi possível carregar as áreas. Verifique a conexão e a chave da API.");
}) } finally {
.finally(() => setAreasLoading(false)); setAreasLoading(false);
}
}, []); }, []);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
return ( return (
<PromptsContext.Provider value={{ prompts, setPrompts, areas, setAreas, areaDescriptions, setAreaDescriptions, areaItems, areaIdByNome, areasLoading, areasError }}> <PromptsContext.Provider value={{ prompts, setPrompts, areas, setAreas, areaDescriptions, setAreaDescriptions, areaItems, areaIdByNome, areasLoading, areasError, refreshAreas }}>
{children} {children}
</PromptsContext.Provider> </PromptsContext.Provider>
); );
+62 -6
View File
@@ -71,7 +71,9 @@ export interface ParecerDetalhe {
user_email: string; user_email: string;
titulo: string; titulo: string;
area_id: string; area_id: string;
prompt_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; prompt_conteudo: string;
instrucao: string; instrucao: string;
conteudo_gerado: string; conteudo_gerado: string;
@@ -84,6 +86,61 @@ export interface ParecerDetalhe {
atualizado_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 { export interface EditarParecerTituloAreaBody {
titulo: string; titulo: string;
area_id: string; area_id: string;
@@ -231,13 +288,12 @@ class ParecerService {
if (!id?.trim()) { if (!id?.trim()) {
throw new Error("ID do parecer é obrigatório"); throw new Error("ID do parecer é obrigatório");
} }
const response = await apiService.get<ParecerDetalhe[] | ParecerDetalhe>(PARECER_DETALHE_URL(id)); const response = await apiService.get<unknown>(PARECER_DETALHE_URL(id));
const raw = response.data; const row = extrairParecerDetalheRaw(response.data);
const item = Array.isArray(raw) ? raw[0] : raw; if (!row?.id) {
if (!item || typeof item !== "object" || !("id" in item)) {
throw new Error("Parecer não encontrado"); throw new Error("Parecer não encontrado");
} }
return item as ParecerDetalhe; return normalizarParecerDetalhe(row);
} }
async enviarMensagemChat( async enviarMensagemChat(
+37
View File
@@ -74,6 +74,16 @@ export interface EditarPromptErrorResponse {
missing_fields?: string[]; missing_fields?: string[];
} }
export interface DeletarPromptSuccessResponse {
success: true;
message: string;
}
export interface DeletarPromptErrorResponse {
success: false;
message?: string;
}
class PromptsService { class PromptsService {
async listar(params: ListarPromptsParams): Promise<ListarPromptsResponseBody> { async listar(params: ListarPromptsParams): Promise<ListarPromptsResponseBody> {
const { page, per_page, titulo, area_id } = params; const { page, per_page, titulo, area_id } = params;
@@ -181,6 +191,33 @@ class PromptsService {
return data as EditarPromptSuccessResponse; 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(); export const promptsService = new PromptsService();