Ajustes e melhorias no Codex
This commit is contained in:
@@ -38,9 +38,21 @@ export function AreasView() {
|
||||
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;
|
||||
@@ -90,7 +102,7 @@ export function AreasView() {
|
||||
const name = newName.trim();
|
||||
const description = newDescription.trim();
|
||||
if (!name) return;
|
||||
if (areas.includes(name)) {
|
||||
if (normalizedAreas.has(name.toLocaleLowerCase())) {
|
||||
toast.error("Já existe uma área com esse nome.");
|
||||
return;
|
||||
}
|
||||
@@ -146,7 +158,10 @@ export function AreasView() {
|
||||
}
|
||||
const name = editName.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.");
|
||||
return;
|
||||
}
|
||||
@@ -412,7 +427,14 @@ export function AreasView() {
|
||||
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>
|
||||
@@ -430,7 +452,7 @@ export function AreasView() {
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreatingArea}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!newName.trim() || areas.includes(newName.trim()) || isCreatingArea}>
|
||||
<Button onClick={handleCreate} disabled={!newName.trim() || isCreateNameDuplicated || isCreatingArea}>
|
||||
{isCreatingArea ? "Criando..." : "Criar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -452,7 +474,14 @@ export function AreasView() {
|
||||
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>
|
||||
@@ -470,7 +499,7 @@ export function AreasView() {
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isSavingEdit}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} disabled={!editName.trim() || isSavingEdit}>
|
||||
<Button onClick={handleSaveEdit} disabled={!editName.trim() || isEditNameDuplicated || isSavingEdit}>
|
||||
{isSavingEdit ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</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 {
|
||||
titulo?: string;
|
||||
conteudoMarkdown?: string;
|
||||
@@ -83,7 +97,7 @@ export function ParecerJuridicoDetailView() {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { areaItems } = usePrompts();
|
||||
const { areaItems, refreshAreas } = usePrompts();
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editTitulo, setEditTitulo] = useState("");
|
||||
@@ -107,6 +121,25 @@ export function ParecerJuridicoDetailView() {
|
||||
const dragStartHeight = useRef(0);
|
||||
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 MAX_INPUT_HEIGHT = 400;
|
||||
|
||||
@@ -135,6 +168,10 @@ export function ParecerJuridicoDetailView() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAreas();
|
||||
}, [refreshAreas]);
|
||||
|
||||
const carregarChat = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!id?.trim()) return;
|
||||
@@ -255,11 +292,29 @@ export function ParecerJuridicoDetailView() {
|
||||
}
|
||||
};
|
||||
|
||||
const abrirModalEdicao = () => {
|
||||
if (!parecer) return;
|
||||
setEditTitulo(parecer.titulo ?? "");
|
||||
setEditAreaId(parecer.area_id ?? "");
|
||||
setEditOpen(true);
|
||||
const handleDownloadAnexo = async () => {
|
||||
if (!parecer?.anexo_url) return;
|
||||
try {
|
||||
const response = await fetch(parecer.anexo_url);
|
||||
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 () => {
|
||||
@@ -441,7 +496,7 @@ export function ParecerJuridicoDetailView() {
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!parecer || !id || editSaving}
|
||||
onClick={abrirModalEdicao}
|
||||
onClick={openEditModal}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Editar
|
||||
@@ -487,14 +542,14 @@ export function ParecerJuridicoDetailView() {
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<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="shrink-0 px-6 pt-6 pb-2 pr-12">
|
||||
<DialogTitle>Detalhes do parecer</DialogTitle>
|
||||
<DialogDescription className="sr-only">Resumo do parecer</DialogDescription>
|
||||
</DialogHeader>
|
||||
{parecer && (
|
||||
<ScrollArea className="max-h-[min(70vh,640px)] px-6 pb-6">
|
||||
<div className="space-y-5 text-sm pr-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6">
|
||||
<div className="space-y-5 text-sm pr-1">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-muted-foreground text-xs">Título</Label>
|
||||
@@ -507,13 +562,21 @@ export function ParecerJuridicoDetailView() {
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Área</Label>
|
||||
<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>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<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>
|
||||
{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>
|
||||
<Label className="text-muted-foreground text-xs">Criado em</Label>
|
||||
<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>
|
||||
<p className="mt-1">{formatarDataDetalhe(parecer.updated_at)}</p>
|
||||
</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">
|
||||
<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">
|
||||
@@ -529,30 +604,59 @@ export function ParecerJuridicoDetailView() {
|
||||
{parecer.anexo_nome ?? (parecer.anexo_url ? "Arquivo anexado" : "—")}
|
||||
</p>
|
||||
{parecer.anexo_url ? (
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit shrink-0 gap-2" asChild>
|
||||
<a href={parecer.anexo_url} target="_blank" rel="noreferrer" download>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit shrink-0 gap-2"
|
||||
onClick={() => void handleDownloadAnexo()}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Prompt utilizado</Label>
|
||||
<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">
|
||||
{parecer.prompt_conteudo || "—"}
|
||||
</div>
|
||||
<Label htmlFor="detalhes-prompt" className="text-muted-foreground text-xs">
|
||||
Prompt utilizado
|
||||
</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>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<Dialog
|
||||
open={editOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEditOpen(open);
|
||||
if (open) {
|
||||
aplicarDadosAtuaisNoFormularioEdicao();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md" key={editOpen && parecer ? `edit-${parecer.id}` : "edit-closed"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar título e área</DialogTitle>
|
||||
<DialogDescription>Atualize os campos obrigatórios do parecer.</DialogDescription>
|
||||
@@ -585,12 +689,23 @@ export function ParecerJuridicoDetailView() {
|
||||
</span>
|
||||
<span className="sr-only">obrigatório</span>
|
||||
</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">
|
||||
<SelectValue placeholder="Selecione a área" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.nome}
|
||||
@@ -716,10 +831,10 @@ export function ParecerJuridicoDetailView() {
|
||||
</Dialog>
|
||||
|
||||
<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">
|
||||
<div className="p-4 md:p-6 bg-white 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">
|
||||
<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 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() ? (
|
||||
<ReactMarkdown>{conteudoMarkdown}</ReactMarkdown>
|
||||
) : (
|
||||
|
||||
@@ -43,7 +43,7 @@ function statusEstilo(status: string): { dot: string; text: string; label: strin
|
||||
|
||||
export const ParecerJuridicoView = () => {
|
||||
const navigate = useNavigate();
|
||||
const { areaItems } = usePrompts();
|
||||
const { areaItems, refreshAreas } = usePrompts();
|
||||
|
||||
const [data, setData] = useState<ParecerListItem[]>([]);
|
||||
const [totalRegistros, setTotalRegistros] = useState(0);
|
||||
@@ -95,6 +95,10 @@ export const ParecerJuridicoView = () => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAreas();
|
||||
}, [refreshAreas]);
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setPerPage(Number(value));
|
||||
setPaginaAtual(1);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
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 { 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";
|
||||
@@ -26,10 +27,12 @@ function apiItemToPrompt(item: { id: string; titulo: string; area_nome: string;
|
||||
|
||||
export const PromptsView = () => {
|
||||
const navigate = useNavigate();
|
||||
const { prompts, setPrompts, areaItems } = usePrompts();
|
||||
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);
|
||||
|
||||
@@ -39,6 +42,7 @@ export const PromptsView = () => {
|
||||
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);
|
||||
|
||||
@@ -88,6 +92,10 @@ export const PromptsView = () => {
|
||||
setCurrentPage(1);
|
||||
}, [filterNome, filterAreaId]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAreas();
|
||||
}, [refreshAreas]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterNome("");
|
||||
setFilterAreaId("");
|
||||
@@ -112,6 +120,35 @@ export const PromptsView = () => {
|
||||
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">
|
||||
@@ -240,6 +277,15 @@ export const PromptsView = () => {
|
||||
>
|
||||
<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>
|
||||
@@ -365,6 +411,27 @@ export const PromptsView = () => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { toast } from "sonner";
|
||||
|
||||
@@ -35,6 +35,7 @@ interface PromptsContextValue {
|
||||
areaIdByNome: AreaIdByNome;
|
||||
areasLoading: boolean;
|
||||
areasError: string | null;
|
||||
refreshAreas: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PromptsContext = createContext<PromptsContextValue | null>(null);
|
||||
@@ -48,30 +49,34 @@ export function PromptsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [areasLoading, setAreasLoading] = useState(true);
|
||||
const [areasError, setAreasError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshAreas = useCallback(async () => {
|
||||
setAreasLoading(true);
|
||||
setAreasError(null);
|
||||
areasService.listarTotal()
|
||||
.then((data) => {
|
||||
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?.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));
|
||||
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 }}>
|
||||
<PromptsContext.Provider value={{ prompts, setPrompts, areas, setAreas, areaDescriptions, setAreaDescriptions, areaItems, areaIdByNome, areasLoading, areasError, refreshAreas }}>
|
||||
{children}
|
||||
</PromptsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,9 @@ export interface ParecerDetalhe {
|
||||
user_email: string;
|
||||
titulo: 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;
|
||||
instrucao: string;
|
||||
conteudo_gerado: string;
|
||||
@@ -84,6 +86,61 @@ export interface ParecerDetalhe {
|
||||
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;
|
||||
@@ -231,13 +288,12 @@ class ParecerService {
|
||||
if (!id?.trim()) {
|
||||
throw new Error("ID do parecer é obrigatório");
|
||||
}
|
||||
const response = await apiService.get<ParecerDetalhe[] | ParecerDetalhe>(PARECER_DETALHE_URL(id));
|
||||
const raw = response.data;
|
||||
const item = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!item || typeof item !== "object" || !("id" in item)) {
|
||||
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 item as ParecerDetalhe;
|
||||
return normalizarParecerDetalhe(row);
|
||||
}
|
||||
|
||||
async enviarMensagemChat(
|
||||
|
||||
@@ -74,6 +74,16 @@ export interface EditarPromptErrorResponse {
|
||||
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;
|
||||
@@ -181,6 +191,33 @@ class PromptsService {
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user