531 lines
20 KiB
TypeScript
531 lines
20 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { Plus, Search, Eye, Trash2, ArrowUpDown, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { OpinionDialog } from "./OpinionDialog";
|
|
import { AgentSearch } from "./AgentSearch";
|
|
import { agentService, OpinionRecord } from "@/services/agent";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
type SortField = "titulo" | "created_at" | "categoria";
|
|
type SortOrder = "asc" | "desc";
|
|
|
|
export const AgentView = () => {
|
|
const [opinions, setOpinions] = useState<OpinionRecord[]>([]);
|
|
const [pendingOpinions, setPendingOpinions] = useState<OpinionRecord[]>([]); // Registros temporários sendo processados
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
const [sortField, setSortField] = useState<SortField>("created_at");
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [selectedOpinion, setSelectedOpinion] = useState<OpinionRecord | null>(null);
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [opinionToDelete, setOpinionToDelete] = useState<OpinionRecord | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
// Carrega pareceres da API
|
|
const loadOpinions = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const data = await agentService.getOpinions({
|
|
page: currentPage,
|
|
per_page: itemsPerPage,
|
|
search: searchTerm,
|
|
});
|
|
|
|
// Adiciona status aos pareceres da API
|
|
const opinionsWithStatus = data.map(opinion => ({
|
|
...opinion,
|
|
status: (opinion.file_url || opinion.file_url_melhoria) ? 'concluido' : 'processando' as const,
|
|
}));
|
|
|
|
setOpinions(opinionsWithStatus);
|
|
|
|
// Remove registros temporários que agora estão na API
|
|
setPendingOpinions(prev =>
|
|
prev.filter(pending =>
|
|
!opinionsWithStatus.some(opinion =>
|
|
opinion.titulo === pending.titulo &&
|
|
opinion.created_at.substring(0, 10) === pending.created_at.substring(0, 10)
|
|
)
|
|
)
|
|
);
|
|
} catch (error: any) {
|
|
console.error('Erro ao carregar pareceres:', error);
|
|
toast({
|
|
title: "Erro ao carregar pareceres",
|
|
description: error.message || "Não foi possível carregar a lista de pareceres.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [currentPage, itemsPerPage, searchTerm, toast]);
|
|
|
|
// Polling automático para atualizar status dos pareceres
|
|
useEffect(() => {
|
|
// Carrega imediatamente
|
|
loadOpinions();
|
|
|
|
// Configura polling a cada 10 segundos se houver pareceres pendentes
|
|
const interval = setInterval(() => {
|
|
if (pendingOpinions.length > 0) {
|
|
console.log('Polling: Atualizando lista de pareceres...');
|
|
loadOpinions();
|
|
}
|
|
}, 10000); // 10 segundos
|
|
|
|
return () => clearInterval(interval);
|
|
}, [currentPage, itemsPerPage, pendingOpinions.length, loadOpinions]);
|
|
|
|
// Debounce para busca
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
if (currentPage === 1) {
|
|
loadOpinions();
|
|
} else {
|
|
setCurrentPage(1); // Volta para a primeira página ao buscar
|
|
}
|
|
}, 500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchTerm, loadOpinions]);
|
|
|
|
const handleSort = (field: SortField) => {
|
|
if (sortField === field) {
|
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
|
} else {
|
|
setSortField(field);
|
|
setSortOrder("asc");
|
|
}
|
|
};
|
|
|
|
// Mescla pareceres da API com registros temporários
|
|
const allOpinions = [...pendingOpinions, ...opinions];
|
|
|
|
const sortedOpinions = [...allOpinions].sort((a, b) => {
|
|
const multiplier = sortOrder === "asc" ? 1 : -1;
|
|
|
|
if (sortField === "created_at") {
|
|
return multiplier * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
}
|
|
|
|
const aValue = a[sortField] || "";
|
|
const bValue = b[sortField] || "";
|
|
return multiplier * aValue.toString().localeCompare(bValue.toString());
|
|
});
|
|
|
|
const totalPages = Math.ceil(sortedOpinions.length / itemsPerPage);
|
|
|
|
// Callback quando um parecer está sendo criado (registro temporário)
|
|
const handleOpinionCreating = (tempOpinion: OpinionRecord) => {
|
|
setPendingOpinions(prev => [tempOpinion, ...prev]);
|
|
};
|
|
|
|
// Callback quando um parecer foi criado (recarrega da API)
|
|
const handleOpinionCreated = () => {
|
|
loadOpinions();
|
|
};
|
|
|
|
const handleViewOpinion = (opinion: OpinionRecord) => {
|
|
setSelectedOpinion(opinion);
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const handleNewOpinion = () => {
|
|
setSelectedOpinion(null);
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const handleSelectFromSearch = (opinion: OpinionRecord) => {
|
|
setSelectedOpinion(opinion);
|
|
setShowSearch(false);
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const handleDownloadVersion = async (opinion: OpinionRecord, version: 'v1' | 'v2') => {
|
|
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
|
|
|
|
if (!fileUrl) {
|
|
toast({
|
|
title: "Arquivo não disponível",
|
|
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
|
|
await agentService.downloadOpinion(fileUrl, fileName);
|
|
|
|
toast({
|
|
title: "Download iniciado",
|
|
description: "O parecer está sendo baixado.",
|
|
});
|
|
} catch (error: any) {
|
|
toast({
|
|
title: "Erro ao fazer download",
|
|
description: error.message || "Não foi possível baixar o arquivo.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteOpinion = async () => {
|
|
if (!opinionToDelete) return;
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
await agentService.deleteOpinion(opinionToDelete.id);
|
|
|
|
toast({
|
|
title: "Parecer excluído",
|
|
description: "O parecer foi excluído com sucesso.",
|
|
});
|
|
|
|
// Recarrega a lista de pareceres
|
|
await loadOpinions();
|
|
|
|
// Fecha o diálogo de confirmação
|
|
setOpinionToDelete(null);
|
|
} catch (error: any) {
|
|
toast({
|
|
title: "Erro ao excluir parecer",
|
|
description: error.message || "Não foi possível excluir o parecer.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
if (showSearch) {
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-6 border-b border-border">
|
|
<Button variant="outline" onClick={() => setShowSearch(false)}>
|
|
Voltar para Pareceres
|
|
</Button>
|
|
</div>
|
|
<AgentSearch onSelectOpinion={handleSelectFromSearch} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">Gerador de Modelos de Parecer Jurídico</h1>
|
|
<div className="flex gap-2 w-full md:w-auto">
|
|
<Button onClick={() => setShowSearch(true)} variant="outline" className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
|
<Search className="w-3 h-3 md:w-4 md:h-4" />
|
|
<span className="hidden sm:inline">Pesquisar Base</span>
|
|
<span className="sm:hidden">Base</span>
|
|
</Button>
|
|
<Button onClick={handleNewOpinion} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
|
<span className="hidden sm:inline">Novo Parecer</span>
|
|
<span className="sm:hidden">Novo</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
|
|
<Input
|
|
placeholder="Buscar pareceres..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full md:max-w-sm text-sm"
|
|
/>
|
|
<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={(value) => {
|
|
setItemsPerPage(Number(value));
|
|
setCurrentPage(1);
|
|
}}
|
|
>
|
|
<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">
|
|
<div className="border rounded-lg overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="min-w-[200px]">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => handleSort("titulo")}
|
|
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
|
|
>
|
|
Título
|
|
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead className="hidden md:table-cell">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => handleSort("categoria")}
|
|
className="flex items-center gap-1 font-semibold text-sm"
|
|
>
|
|
Categoria
|
|
<ArrowUpDown className="w-4 h-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead className="hidden lg:table-cell text-center">
|
|
<span className="font-semibold text-xs md:text-sm">Status</span>
|
|
</TableHead>
|
|
<TableHead className="hidden sm:table-cell">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => handleSort("created_at")}
|
|
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
|
|
>
|
|
Data
|
|
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
|
|
Carregando pareceres...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : sortedOpinions.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
|
|
{searchTerm
|
|
? "Nenhum parecer encontrado"
|
|
: "Nenhum parecer criado ainda. Clique em 'Novo Parecer' para começar."}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
sortedOpinions.map((opinion) => (
|
|
<TableRow key={opinion.id}>
|
|
<TableCell className="font-medium text-xs md:text-sm">{opinion.titulo}</TableCell>
|
|
<TableCell className="hidden md:table-cell text-sm">{opinion.categoria || "-"}</TableCell>
|
|
<TableCell className="hidden lg:table-cell text-center">
|
|
{opinion.status === 'processando' && (
|
|
<Badge variant="secondary" className="gap-1 bg-yellow-100 text-yellow-800 hover:bg-yellow-100">
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
Gerando...
|
|
</Badge>
|
|
)}
|
|
{opinion.status === 'concluido' && (
|
|
<Badge variant="secondary" className="gap-1 bg-green-100 text-green-800 hover:bg-green-100">
|
|
<CheckCircle2 className="w-3 h-3" />
|
|
Concluído
|
|
</Badge>
|
|
)}
|
|
{opinion.status === 'erro' && (
|
|
<Badge variant="destructive" className="gap-1">
|
|
<XCircle className="w-3 h-3" />
|
|
Erro
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="hidden sm:table-cell text-xs md:text-sm">
|
|
{new Date(opinion.created_at).toLocaleDateString("pt-BR")}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleViewOpinion(opinion)}
|
|
title="Visualizar"
|
|
className="h-7 w-7 md:h-9 md:w-9"
|
|
disabled={opinion.isLocalPending || opinion.status === 'processando'}
|
|
>
|
|
<Eye className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
title="Baixar"
|
|
className="h-7 w-7 md:h-9 md:w-9"
|
|
disabled={opinion.isLocalPending || opinion.status === 'processando'}
|
|
>
|
|
<Download className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => handleDownloadVersion(opinion, 'v1')}
|
|
disabled={!opinion.file_url}
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Versão 1
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => handleDownloadVersion(opinion, 'v2')}
|
|
disabled={!opinion.file_url_melhoria}
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Versão Melhorada
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setOpinionToDelete(opinion)}
|
|
title="Excluir"
|
|
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive"
|
|
disabled={opinion.isLocalPending}
|
|
>
|
|
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{sortedOpinions.length > 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 {sortedOpinions.length} {sortedOpinions.length === 1 ? 'parecer' : 'pareceres'}
|
|
</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;
|
|
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>
|
|
|
|
<OpinionDialog
|
|
open={isDialogOpen}
|
|
onOpenChange={setIsDialogOpen}
|
|
selectedOpinion={selectedOpinion}
|
|
onOpinionCreated={handleOpinionCreated}
|
|
onOpinionCreating={handleOpinionCreating}
|
|
/>
|
|
|
|
<AlertDialog open={!!opinionToDelete} onOpenChange={(open) => !open && setOpinionToDelete(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Tem certeza que deseja excluir o parecer "{opinionToDelete?.titulo}"? Esta ação não pode ser desfeita.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDeleteOpinion}
|
|
disabled={isDeleting}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{isDeleting ? "Excluindo..." : "Excluir"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
};
|