Nova versao codex Parecer, atualizacao completa

This commit is contained in:
Vitex Tecnologia
2026-03-18 01:07:27 -03:00
parent 39032ea621
commit 6bf7e7d42e
49 changed files with 8003 additions and 2486 deletions
@@ -0,0 +1,248 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import {
Bot,
Sparkles,
FileText,
Wand2,
SlidersHorizontal,
ListChecks,
MessageSquareText,
} from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Terminal, AnimatedStep } from "@/components/ui/terminal";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { AnimatedBeam } from "@/components/ui/animated-beam";
import { cn } from "@/lib/utils";
const PROMPT_CREATE_STEPS = [
"Interpretando instruções e contexto...",
"Definindo objetivo, público e tom...",
"Organizando estrutura do prompt (seções e regras)...",
"Especificando formato de saída e restrições...",
"Adicionando critérios de qualidade e validações...",
"Incluindo exemplos e casos de borda...",
"Revisando clareza e consistência...",
"Finalizando prompt...",
] as const;
const PROMPT_REFINE_STEPS = [
"Lendo o prompt atual...",
"Identificando ambiguidades e pontos fracos...",
"Removendo redundâncias e ruído...",
"Aprimorando instruções e critérios de sucesso...",
"Ajustando tom, voz e consistência...",
"Fortalecendo formato de saída e validações...",
"Adicionando exemplos e casos de borda...",
"Consolidando melhorias e finalizando...",
] as const;
const STEP_INTERVAL_MS = 1500;
export type PromptGeneratingMode = "create" | "refine";
export interface PromptGeneratingScreenProps {
mode: PromptGeneratingMode;
progress?: number;
message?: string;
}
function visibleLineCount(progress: number, stepCount: number): number {
if (progress <= 0) return 1;
const phase = (progress / 100) * stepCount;
const stepInCycle = Math.floor(phase) % stepCount;
return Math.min(stepInCycle + 1, stepCount);
}
const BeamCircle = forwardRef<
HTMLDivElement,
{ className?: string; children?: React.ReactNode }
>(({ className, children }, ref) => (
<div
ref={ref}
className={cn(
"z-10 flex size-12 items-center justify-center rounded-full border-2 border-slate-300 bg-slate-100 text-slate-700 shadow-sm",
className
)}
>
{children}
</div>
));
BeamCircle.displayName = "BeamCircle";
export function PromptGeneratingScreen({
mode,
progress,
message = "Assim que finalizar, o modal será fechado automaticamente.",
}: PromptGeneratingScreenProps) {
const steps = mode === "refine" ? PROMPT_REFINE_STEPS : PROMPT_CREATE_STEPS;
const stepCount = steps.length;
const containerRef = useRef<HTMLDivElement>(null);
const sparklesRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLDivElement>(null);
const slidersRef = useRef<HTMLDivElement>(null);
const centerRef = useRef<HTMLDivElement>(null);
const wandRef = useRef<HTMLDivElement>(null);
const checklistRef = useRef<HTMLDivElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
const [loopStep, setLoopStep] = useState(0);
useEffect(() => {
if (progress !== undefined) return;
const id = setInterval(() => {
setLoopStep((s) => (s + 1) % stepCount);
}, STEP_INTERVAL_MS);
return () => clearInterval(id);
}, [progress, stepCount]);
const visibleCount =
progress !== undefined
? visibleLineCount(progress, stepCount)
: loopStep + 1;
return (
<Dialog open>
<DialogContent
hideClose
overlayClassName="bg-black/50"
className="max-w-2xl gap-0 overflow-hidden bg-white p-0 sm:rounded-xl"
>
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3 rounded-t-lg border border-b-0 border-border bg-zinc-100 px-3 py-2.5">
<div className="flex gap-1.5">
<span className="size-3 rounded-full bg-[#ff5f57]" aria-hidden />
<span className="size-3 rounded-full bg-[#febc2e]" aria-hidden />
<span className="size-3 rounded-full bg-[#28c840]" aria-hidden />
</div>
<span className="font-mono text-xs text-muted-foreground">
codex prompt {mode === "refine" ? "melhorando" : "gerando"}
</span>
</div>
<Terminal className="min-h-[200px] space-y-2 rounded-t-none border-t-0 bg-white p-4 font-mono text-sm">
<AnimatedStep delay={0} className="text-muted-foreground">
{`> codex prompt --${mode === "refine" ? "refine" : "create"}`}
</AnimatedStep>
{steps.slice(0, visibleCount).map((text, i) => (
<div key={`${visibleCount}-${i}-${text}`} className="flex items-center gap-0">
{i < visibleCount - 1 ? (
<span className="text-sky-600 dark:text-sky-400">
{text}
</span>
) : (
<TypingAnimation
key={`typing-${visibleCount}`}
startOnView={false}
showCursor
cursorStyle="block"
className="text-sky-600 dark:text-sky-400"
>
{`${text}`}
</TypingAnimation>
)}
</div>
))}
</Terminal>
{message && (
<p className="text-center text-sm text-muted-foreground">
{message}
</p>
)}
<div className="rounded-xl border border-border bg-white p-6">
<div
ref={containerRef}
className="relative flex h-[260px] w-full items-center justify-center overflow-hidden"
>
<div className="flex size-full max-h-[220px] max-w-lg flex-col items-stretch justify-between">
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={sparklesRef}>
<Sparkles className="size-6 text-sky-500" />
</BeamCircle>
<BeamCircle ref={fileRef}>
<FileText className="size-6 text-slate-600" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={slidersRef}>
<SlidersHorizontal className="size-6 text-indigo-500" />
</BeamCircle>
<BeamCircle ref={centerRef} className="size-14 border-slate-400 bg-slate-200">
<Bot className="size-8 text-slate-700" />
</BeamCircle>
<BeamCircle ref={wandRef}>
<Wand2 className="size-6 text-amber-500" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={checklistRef}>
<ListChecks className="size-6 text-slate-600" />
</BeamCircle>
<BeamCircle ref={chatRef}>
<MessageSquareText className="size-6 text-slate-600" />
</BeamCircle>
</div>
</div>
<AnimatedBeam
containerRef={containerRef}
fromRef={sparklesRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
gradientStartColor="#0ea5e9"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={fileRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={slidersRef}
toRef={centerRef}
gradientStartColor="#6366f1"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={wandRef}
toRef={centerRef}
reverse
gradientStartColor="#f59e0b"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={checklistRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={chatRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+423
View File
@@ -0,0 +1,423 @@
import React, { useState, useEffect, useRef, forwardRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, ArrowRight, Sparkles, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ShimmerButton } from "@/components/ui/shimmer-button";
import { SparklesText } from "@/components/ui/sparkles-text";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { BorderBeam } from "@/components/ui/border-beam";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
import { cn } from "@/lib/utils";
import { usePrompts } from "@/contexts/PromptsContext";
import { areasService, type AreaItem } from "@/services/areas";
import { PromptGeneratingScreen } from "@/components/prompts/PromptGeneratingScreen";
import { assistentePromptsService } from "@/services/assistentePrompts";
import { promptsService } from "@/services/promptsApi";
import { toast } from "sonner";
import { playSuccessSound } from "@/utils/sound";
export function PromptsFormView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { prompts, setPrompts } = usePrompts();
const isEdit = Boolean(id);
const prompt = id ? prompts.find((p) => p.id === id) : null;
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
const [areasLoading, setAreasLoading] = useState(true);
const [titulo, setTitulo] = useState("");
const [descricao, setDescricao] = useState("");
const [areaId, setAreaId] = useState("");
const [conteudo, setConteudo] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(false);
const [assistantMode, setAssistantMode] = useState<"create" | "refine">("create");
const [assistantInstructions, setAssistantInstructions] = useState("");
const [isAssistantLoading, setIsAssistantLoading] = useState(false);
const [isAssistantWorkingInBackground, setIsAssistantWorkingInBackground] = useState(false);
const [showGeneratingModal, setShowGeneratingModal] = useState(false);
const confettiRef = useRef<ConfettiRef>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const hasContent = conteudo.trim().length > 0;
const effectiveAreaId = areaId && areaItems.some((a) => a.id === areaId) ? areaId : (areaItems[0]?.id ?? "");
const effectiveAreaName = areaItems.find((a) => a.id === effectiveAreaId)?.nome ?? "";
useEffect(() => {
setAreasLoading(true);
areasService.listarTotal()
.then((data) => setAreaItems(data))
.catch(() => setAreaItems([]))
.finally(() => setAreasLoading(false));
}, []);
useEffect(() => {
if (prompt) {
setTitulo(prompt.titulo);
setDescricao(prompt.descricao ?? "");
setAreaId(prompt.area_id ?? "");
setConteudo(prompt.conteudo ?? "");
} else if (!id) {
setTitulo("");
setDescricao("");
setAreaId("");
setConteudo("");
}
}, [prompt, id]);
useEffect(() => {
if (!id && areaItems.length > 0 && !areaId) {
setAreaId(areaItems[0].id);
}
}, [id, areaItems, areaId]);
useEffect(() => {
if (id && !prompt && prompts.length > 0) {
navigate("/codex/prompts", { replace: true });
}
}, [id, prompt, prompts.length, navigate]);
useEffect(() => {
if (!conteudo.trim() && assistantMode === "refine") {
setAssistantMode("create");
}
}, [conteudo, assistantMode]);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
const handleSave = async () => {
const finalTitulo = titulo.trim();
const finalAreaId = effectiveAreaId;
const finalConteudo = conteudo.trim();
if (!finalTitulo || !finalAreaId) {
toast.error("Preencha o título e a área.");
return;
}
if (!finalConteudo) {
toast.error("Preencha o conteúdo do prompt.");
return;
}
const finalAreaName = areaItems.find((a) => a.id === finalAreaId)?.nome ?? "";
const finalDescricao = descricao.trim();
setIsSaving(true);
try {
if (isEdit && prompt) {
const res = await promptsService.editar(prompt.id, {
titulo: finalTitulo,
descricao: finalDescricao,
area_id: finalAreaId,
conteudo: finalConteudo,
});
setPrompts((prev) =>
prev.map((p) =>
p.id === prompt.id
? { ...p, id: res.id, titulo: res.titulo, area: finalAreaName, area_id: res.area_id, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }
: p
)
);
toast.success("Prompt atualizado.");
} else {
const res = await promptsService.criar({
titulo: finalTitulo,
descricao: finalDescricao,
area_id: finalAreaId,
conteudo: finalConteudo,
});
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
toast.success("Prompt criado.");
}
navigate("/codex/prompts");
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleAssistantSubmit = () => {
if (!assistantInstructions.trim()) {
toast.error("Preencha as instruções.");
return;
}
if (assistantMode === "refine" && !conteudo.trim()) {
toast.error("Não há conteúdo para melhorar.");
return;
}
setIsAssistantOpen(false);
setShowGeneratingModal(true);
setIsAssistantWorkingInBackground(true);
setIsAssistantLoading(true);
const ac = new AbortController();
abortControllerRef.current = ac;
const signal = ac.signal;
const onSuccess = (text: string) => {
setConteudo(text);
confettiRef.current?.fire({});
playSuccessSound();
const msg =
assistantMode === "create" ? "Modelo inicial gerado!" : "Conteúdo atualizado!";
toast.success(msg, {
className: "bg-green-600 border-green-700 text-white",
});
};
const onFinish = () => {
setShowGeneratingModal(false);
setIsAssistantLoading(false);
setIsAssistantWorkingInBackground(false);
abortControllerRef.current = null;
};
const onError = (e: unknown) => {
const isAborted =
(e && typeof e === "object" && "code" in e && (e as { code: string }).code === "ERR_CANCELED") ||
(e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError");
if (!isAborted) {
const message =
e && typeof e === "object" && "message" in e
? (e as { message: string }).message
: "Erro ao processar.";
toast.error(message);
}
};
if (assistantMode === "create") {
assistentePromptsService.gerar(assistantInstructions, signal)
.then(onSuccess)
.catch(onError)
.finally(onFinish);
} else {
assistentePromptsService.refinar(conteudo, assistantInstructions, signal)
.then(onSuccess)
.catch(onError)
.finally(onFinish);
}
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0 relative">
<Confetti
ref={confettiRef}
manualstart
className="absolute inset-0 size-full pointer-events-none z-50"
/>
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
<div className="flex flex-col gap-3">
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/codex/prompts")}>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[200px] space-y-2">
<Label htmlFor="form-titulo">Título</Label>
<Input
id="form-titulo"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Ex: Prompt para análise de documentos"
/>
</div>
<div className="w-full sm:w-[220px] space-y-2">
<Label htmlFor="form-area">Área</Label>
<Select value={effectiveAreaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
<SelectTrigger id="form-area">
<SelectValue placeholder={areasLoading ? "Carregando áreas..." : "Selecione a área"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Selecione a área</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="form-descricao">Descrição <span className="text-muted-foreground font-normal">(opcional)</span></Label>
<Input
id="form-descricao"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
placeholder="Ex: Breve descrição do prompt"
/>
</div>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 p-3 md:p-6 gap-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<Label htmlFor="form-conteudo">Conteúdo do Prompt</Label>
<div className="flex items-center gap-2">
{isAssistantWorkingInBackground && (
<div
className="w-2 h-2 rounded-full bg-primary animate-pulse-glow shrink-0"
title="Gerando em andamento"
/>
)}
<ShimmerButton
type="button"
onClick={() => setIsAssistantOpen(true)}
disabled={isAssistantWorkingInBackground}
background="rgb(255,255,255)"
shimmerColor="rgba(0,0,0,0.06)"
className={cn(
"gap-2 px-5 py-2.5 text-sm font-medium text-neutral-900 border border-neutral-200 shadow-md hover:shadow-lg transition-shadow dark:border-neutral-300",
isAssistantWorkingInBackground && "opacity-70 pointer-events-none"
)}
>
{isAssistantWorkingInBackground ? (
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Gerando em segundo plano...
</span>
) : (
<SparklesText sparklesCount={5} className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
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>
);
}
+368
View File
@@ -0,0 +1,368 @@
import { useState, useMemo, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Plus, Eye, Edit, Copy } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import type { Prompt } from "@/contexts/PromptsContext";
import { promptsService } from "@/services/promptsApi";
import { toast } from "sonner";
function apiItemToPrompt(item: { id: string; titulo: string; area_nome: string; area_id: string; descricao?: string; conteudo?: string }): Prompt {
return {
id: item.id,
titulo: item.titulo,
area: item.area_nome,
area_id: item.area_id,
descricao: item.descricao ?? undefined,
conteudo: item.conteudo ?? "",
};
}
export const PromptsView = () => {
const navigate = useNavigate();
const { prompts, setPrompts, areaItems } = usePrompts();
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [selectedPrompt, setSelectedPrompt] = 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 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]);
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}`);
};
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>
<Button onClick={() => navigate("/codex/prompts/novo")}>
<Plus className="w-4 h-4 mr-2" />
Novo prompt
</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">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={3} className="text-center text-muted-foreground py-8">
Carregando...
</TableCell>
</TableRow>
) : promptsList.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground py-10 text-base">
Não resultados.
</TableCell>
</TableRow>
) : (
promptsList.map((prompt) => (
<TableRow key={prompt.id}>
<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>
</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">
<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>
</div>
);
};