460 lines
19 KiB
TypeScript
460 lines
19 KiB
TypeScript
import { useState, useMemo, useEffect } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { FileText, Plus, Eye, Edit, Copy, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { usePrompts } from "@/contexts/PromptsContext";
|
|
import type { Prompt } from "@/contexts/PromptsContext";
|
|
import { promptsService, type PromptItem } from "@/services/promptsApi";
|
|
import { toast } from "sonner";
|
|
|
|
function formatarDataCriacao(iso: string | undefined): string {
|
|
if (!iso?.trim()) return "—";
|
|
try {
|
|
return new Date(iso.replace(" ", "T")).toLocaleDateString("pt-BR");
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function apiItemToPrompt(item: PromptItem): Prompt {
|
|
return {
|
|
id: item.id,
|
|
titulo: item.titulo,
|
|
area: item.area_nome,
|
|
area_id: item.area_id,
|
|
descricao: item.descricao ?? undefined,
|
|
conteudo: item.conteudo ?? "",
|
|
created_at: item.created_at,
|
|
};
|
|
}
|
|
|
|
export const PromptsView = () => {
|
|
const navigate = useNavigate();
|
|
const { prompts, setPrompts, areaItems, refreshAreas } = usePrompts();
|
|
|
|
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
|
const [promptToDelete, setPromptToDelete] = useState<Prompt | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
|
|
const [filterNome, setFilterNome] = useState("");
|
|
const [filterAreaId, setFilterAreaId] = useState<string>("");
|
|
const [promptsList, setPromptsList] = useState<Prompt[]>([]);
|
|
const [totalRegistros, setTotalRegistros] = useState(0);
|
|
const [totalPaginas, setTotalPaginas] = useState(1);
|
|
const [loadingList, setLoadingList] = useState(true);
|
|
const [isDeletingPrompt, setIsDeletingPrompt] = useState(false);
|
|
|
|
const totalPages = Math.max(1, totalPaginas);
|
|
|
|
const selectedAreaItem = useMemo(
|
|
() => (selectedPrompt ? areaItems.find((a) => a.nome === selectedPrompt.area || a.id === selectedPrompt.area_id) ?? null : null),
|
|
[selectedPrompt, areaItems]
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoadingList(true);
|
|
promptsService.listar({
|
|
titulo: filterNome.trim() || undefined,
|
|
area_id: filterAreaId.trim() || undefined,
|
|
page: currentPage,
|
|
per_page: itemsPerPage,
|
|
})
|
|
.then((res) => {
|
|
if (!cancelled) {
|
|
const list = (res.data ?? []).map(apiItemToPrompt);
|
|
setPromptsList(list);
|
|
setTotalRegistros(res.total_registros ?? 0);
|
|
setTotalPaginas(res.total_paginas ?? 1);
|
|
setPrompts((prev) => {
|
|
const byId = new Map(prev.map((p) => [p.id, p]));
|
|
list.forEach((p) => byId.set(p.id, p));
|
|
return Array.from(byId.values());
|
|
});
|
|
}
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (!cancelled) {
|
|
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao carregar prompts.";
|
|
toast.error(msg);
|
|
setPromptsList([]);
|
|
setTotalRegistros(0);
|
|
setTotalPaginas(1);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoadingList(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [filterNome, filterAreaId, currentPage, itemsPerPage]);
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [filterNome, filterAreaId]);
|
|
|
|
useEffect(() => {
|
|
refreshAreas();
|
|
}, [refreshAreas]);
|
|
|
|
const clearFilters = () => {
|
|
setFilterNome("");
|
|
setFilterAreaId("");
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const hasActiveFilters = filterNome.trim() !== "" || filterAreaId !== "";
|
|
|
|
const handleItemsPerPageChange = (value: string) => {
|
|
setItemsPerPage(Number(value));
|
|
setCurrentPage(1);
|
|
};
|
|
|
|
const openDetailsDialog = (prompt: Prompt) => {
|
|
setSelectedPrompt(prompt);
|
|
setIsDetailsDialogOpen(true);
|
|
};
|
|
|
|
const openEditFromDetails = () => {
|
|
if (!selectedPrompt) return;
|
|
setIsDetailsDialogOpen(false);
|
|
navigate(`/commander/prompts/${selectedPrompt.id}`);
|
|
};
|
|
|
|
const openDeleteDialog = (prompt: Prompt) => {
|
|
setPromptToDelete(prompt);
|
|
setIsDeleteDialogOpen(true);
|
|
};
|
|
|
|
const handleDeletePrompt = async () => {
|
|
if (!promptToDelete) return;
|
|
setIsDeletingPrompt(true);
|
|
try {
|
|
await promptsService.deletar(promptToDelete.id);
|
|
setPromptsList((prev) => prev.filter((p) => p.id !== promptToDelete.id));
|
|
setPrompts((prev) => prev.filter((p) => p.id !== promptToDelete.id));
|
|
setTotalRegistros((prev) => Math.max(0, prev - 1));
|
|
if (selectedPrompt?.id === promptToDelete.id) {
|
|
setIsDetailsDialogOpen(false);
|
|
setSelectedPrompt(null);
|
|
}
|
|
toast.success("Prompt excluído com sucesso!");
|
|
setIsDeleteDialogOpen(false);
|
|
setPromptToDelete(null);
|
|
setCurrentPage(1);
|
|
} catch (e: unknown) {
|
|
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao excluir prompt.";
|
|
toast.error(msg);
|
|
} finally {
|
|
setIsDeletingPrompt(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
|
<div className="p-3 md:p-6 border-b border-border">
|
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
|
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
|
<FileText className="w-5 h-5 md:w-6 md:h-6" />
|
|
Prompts
|
|
</h1>
|
|
<div className="flex gap-2 w-full md:w-auto">
|
|
<Button onClick={() => navigate("/commander/prompts/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
|
<span className="hidden sm:inline">Novo prompt</span>
|
|
<span className="sm:hidden">Novo</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
|
|
<Input
|
|
placeholder="Buscar prompts..."
|
|
value={filterNome}
|
|
onChange={(e) => setFilterNome(e.target.value)}
|
|
className="w-full md:max-w-sm text-sm"
|
|
/>
|
|
<Select value={filterAreaId || "all"} onValueChange={(v) => setFilterAreaId(v === "all" ? "" : v)}>
|
|
<SelectTrigger className="w-full md:w-[180px] text-sm">
|
|
<SelectValue placeholder="Todas as áreas" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Todas as áreas</SelectItem>
|
|
{areaItems.map((a) => (
|
|
<SelectItem key={a.id} value={a.id}>
|
|
{a.nome}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{hasActiveFilters && (
|
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
|
Limpar
|
|
</Button>
|
|
)}
|
|
<div className="flex items-center gap-2 justify-between md:justify-start">
|
|
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
|
|
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
|
<SelectTrigger className="w-16 md:w-20">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="5">5</SelectItem>
|
|
<SelectItem value="10">10</SelectItem>
|
|
<SelectItem value="20">20</SelectItem>
|
|
<SelectItem value="50">50</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
|
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
|
|
<Card className="max-w-2xl mx-auto mt-12">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FileText className="w-5 h-5" />
|
|
Nenhum prompt cadastrado
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Crie seu primeiro prompt para organizar por título e área.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
|
<Button onClick={() => navigate("/commander/prompts/novo")}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Novo prompt
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
<div className="border rounded-lg overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="min-w-[140px] whitespace-nowrap font-semibold text-xs md:text-sm">
|
|
Data de criação
|
|
</TableHead>
|
|
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
|
|
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
|
|
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loadingList ? (
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
|
Carregando...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : totalRegistros === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="text-center text-muted-foreground py-10 text-base">
|
|
{hasActiveFilters ? "Nenhum prompt encontrado para os filtros selecionados." : "Não há resultados."}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
promptsList.map((prompt) => (
|
|
<TableRow key={prompt.id}>
|
|
<TableCell className="text-xs md:text-sm whitespace-nowrap tabular-nums">
|
|
{formatarDataCriacao(prompt.created_at)}
|
|
</TableCell>
|
|
<TableCell className="font-medium text-xs md:text-sm">{prompt.titulo}</TableCell>
|
|
<TableCell className="text-xs md:text-sm">{prompt.area}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => openDetailsDialog(prompt)}
|
|
title="Detalhes"
|
|
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
|
|
>
|
|
<Eye className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate(`/commander/prompts/${prompt.id}`)}
|
|
title="Editar"
|
|
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
|
|
>
|
|
<Edit className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => openDeleteDialog(prompt)}
|
|
title="Excluir"
|
|
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive hover:bg-slate-300 dark:hover:bg-slate-700"
|
|
>
|
|
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{!loadingList && totalRegistros > 0 && (
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
|
|
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
|
|
Mostrando {totalRegistros} {totalRegistros === 1 ? "prompt" : "prompts"}
|
|
</p>
|
|
<div className="flex gap-1 md:gap-2 justify-center">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="text-xs md:text-sm px-2 md:px-4"
|
|
>
|
|
<span className="hidden sm:inline">Anterior</span>
|
|
<span className="sm:hidden">Ant</span>
|
|
</Button>
|
|
<div className="flex items-center gap-1">
|
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
|
let page: number;
|
|
if (totalPages <= 5) {
|
|
page = i + 1;
|
|
} else if (currentPage <= 3) {
|
|
page = i + 1;
|
|
} else if (currentPage >= totalPages - 2) {
|
|
page = totalPages - 4 + i;
|
|
} else {
|
|
page = currentPage - 2 + i;
|
|
}
|
|
return (
|
|
<Button
|
|
key={page}
|
|
variant={currentPage === page ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setCurrentPage(page)}
|
|
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
|
|
>
|
|
{page}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage === totalPages}
|
|
className="text-xs md:text-sm px-2 md:px-4"
|
|
>
|
|
<span className="hidden sm:inline">Próxima</span>
|
|
<span className="sm:hidden">Prox</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal Detalhes */}
|
|
<Dialog open={isDetailsDialogOpen} onOpenChange={setIsDetailsDialogOpen}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle>Detalhes do Prompt</DialogTitle>
|
|
<DialogDescription>
|
|
Informações do prompt selecionado
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{selectedPrompt && (
|
|
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 p-3">
|
|
{selectedPrompt.created_at?.trim() ? (
|
|
<div>
|
|
<Label className="text-muted-foreground">Data de criação</Label>
|
|
<p className="mt-1 text-sm font-medium tabular-nums">{formatarDataCriacao(selectedPrompt.created_at)}</p>
|
|
</div>
|
|
) : null}
|
|
<div>
|
|
<Label className="text-muted-foreground">Título</Label>
|
|
<p className="font-medium mt-1">{selectedPrompt.titulo}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-muted-foreground">Área</Label>
|
|
<p className="mt-1 font-medium">{selectedPrompt.area}</p>
|
|
{selectedAreaItem?.descricao?.trim() && (
|
|
<p className="mt-1 text-sm text-muted-foreground">{selectedAreaItem.descricao}</p>
|
|
)}
|
|
</div>
|
|
{selectedPrompt.conteudo != null && selectedPrompt.conteudo !== "" && (
|
|
<div>
|
|
<Label className="text-muted-foreground">Conteúdo</Label>
|
|
<div className="mt-1 rounded-md border bg-muted/30 p-4 max-h-[50vh] overflow-y-auto">
|
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">{selectedPrompt.conteudo}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={async () => {
|
|
if (selectedPrompt?.conteudo != null) {
|
|
await navigator.clipboard.writeText(selectedPrompt.conteudo);
|
|
toast.success("Prompt copiado para a área de transferência.");
|
|
} else {
|
|
toast.error("Nenhum conteúdo para copiar.");
|
|
}
|
|
}}
|
|
disabled={!selectedPrompt?.conteudo?.trim()}
|
|
>
|
|
<Copy className="w-4 h-4 mr-2" />
|
|
Copiar prompt
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setIsDetailsDialogOpen(false)}>
|
|
Fechar
|
|
</Button>
|
|
<Button onClick={openEditFromDetails}>
|
|
<Edit className="w-4 h-4 mr-2" />
|
|
Editar
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Excluir prompt</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Deseja excluir o prompt "{promptToDelete?.titulo ?? ""}"? Esta ação não pode ser desfeita.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeletingPrompt}>Cancelar</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDeletePrompt}
|
|
disabled={isDeletingPrompt || !promptToDelete}
|
|
className={(isDeletingPrompt || !promptToDelete) ? "opacity-50 cursor-not-allowed" : "bg-destructive text-destructive-foreground hover:bg-destructive/90"}
|
|
>
|
|
{isDeletingPrompt ? "Excluindo..." : "Excluir"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
};
|