🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { X, Sparkles, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
import { FileUploadSection } from './FileUploadSection';
|
||||
|
||||
interface CatalogoModalProps {
|
||||
catalogo?: Catalogo | null;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const categorias = [
|
||||
'Aço',
|
||||
'Adesivo',
|
||||
'Tintas',
|
||||
'Parafusos',
|
||||
'Soldas',
|
||||
'Estruturas',
|
||||
'Materiais'
|
||||
];
|
||||
|
||||
const disciplinas = [
|
||||
'Engenharia Civil',
|
||||
'Engenharia Mecânica',
|
||||
'Arquitetura',
|
||||
'Construção',
|
||||
'Fabricação',
|
||||
'Montagem'
|
||||
];
|
||||
|
||||
export function CatalogoModal({ catalogo, onSave, onClose }: CatalogoModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
titulo: '',
|
||||
categoria: '',
|
||||
disciplina: '',
|
||||
conteudo: '',
|
||||
numero_paginas: '',
|
||||
palavras_chave: [] as string[],
|
||||
arquivo_urls: [] as string[]
|
||||
});
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (catalogo) {
|
||||
setFormData({
|
||||
titulo: catalogo.titulo,
|
||||
categoria: catalogo.categoria,
|
||||
disciplina: catalogo.disciplina,
|
||||
conteudo: catalogo.conteudo,
|
||||
numero_paginas: catalogo.numero_paginas?.toString() || '',
|
||||
palavras_chave: catalogo.palavras_chave || [],
|
||||
arquivo_urls: (catalogo as any).arquivo_urls || []
|
||||
});
|
||||
}
|
||||
}, [catalogo]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addKeyword = () => {
|
||||
if (newKeyword.trim() && !formData.palavras_chave.includes(newKeyword.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
palavras_chave: [...prev.palavras_chave, newKeyword.trim()]
|
||||
}));
|
||||
setNewKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeKeyword = (keyword: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
palavras_chave: prev.palavras_chave.filter(k => k !== keyword)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFilesAnalyzed = (analysisResult: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
titulo: analysisResult.titulo || prev.titulo,
|
||||
categoria: analysisResult.categoria || prev.categoria,
|
||||
disciplina: analysisResult.disciplina || prev.disciplina,
|
||||
conteudo: analysisResult.conteudo || prev.conteudo,
|
||||
palavras_chave: [...new Set([...prev.palavras_chave, ...(analysisResult.palavras_chave || [])])]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFilesUploaded = (urls: string[]) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
arquivo_urls: [...new Set([...prev.arquivo_urls, ...urls])]
|
||||
}));
|
||||
};
|
||||
|
||||
const analyzeWithAI = async () => {
|
||||
if (!formData.conteudo.trim()) {
|
||||
toast.error('Por favor, cole o conteúdo do documento antes de analisar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
// Simulação da análise com IA (seria integração real com Gemini)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Análise simulada baseada no conteúdo
|
||||
const content = formData.conteudo.toLowerCase();
|
||||
|
||||
let suggestedCategoria = '';
|
||||
let suggestedDisciplina = '';
|
||||
let suggestedTitulo = '';
|
||||
let suggestedKeywords: string[] = [];
|
||||
|
||||
// Lógica simples de categorização baseada em palavras-chave
|
||||
if (content.includes('aço') || content.includes('steel')) {
|
||||
suggestedCategoria = 'Aço';
|
||||
suggestedKeywords.push('aço', 'steel');
|
||||
} else if (content.includes('tinta') || content.includes('pintura')) {
|
||||
suggestedCategoria = 'Tintas';
|
||||
suggestedKeywords.push('tinta', 'pintura');
|
||||
} else if (content.includes('parafuso') || content.includes('fixação')) {
|
||||
suggestedCategoria = 'Parafusos';
|
||||
suggestedKeywords.push('parafuso', 'fixação');
|
||||
} else {
|
||||
suggestedCategoria = 'Materiais';
|
||||
}
|
||||
|
||||
if (content.includes('estrutura') || content.includes('construção')) {
|
||||
suggestedDisciplina = 'Engenharia Civil';
|
||||
suggestedKeywords.push('estrutura', 'construção');
|
||||
} else if (content.includes('mecânica') || content.includes('fabricação')) {
|
||||
suggestedDisciplina = 'Engenharia Mecânica';
|
||||
suggestedKeywords.push('mecânica', 'fabricação');
|
||||
} else {
|
||||
suggestedDisciplina = 'Construção';
|
||||
}
|
||||
|
||||
// Extrair título das primeiras linhas
|
||||
const firstLine = formData.conteudo.split('\n')[0];
|
||||
if (firstLine && firstLine.length > 10 && firstLine.length < 100) {
|
||||
suggestedTitulo = firstLine.trim();
|
||||
}
|
||||
|
||||
// Adicionar palavras-chave técnicas comuns
|
||||
if (content.includes('resistência')) suggestedKeywords.push('resistência');
|
||||
if (content.includes('qualidade')) suggestedKeywords.push('qualidade');
|
||||
if (content.includes('especificação')) suggestedKeywords.push('especificação');
|
||||
if (content.includes('norma')) suggestedKeywords.push('norma');
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
titulo: suggestedTitulo || prev.titulo,
|
||||
categoria: suggestedCategoria || prev.categoria,
|
||||
disciplina: suggestedDisciplina || prev.disciplina,
|
||||
palavras_chave: [...new Set([...prev.palavras_chave, ...suggestedKeywords])]
|
||||
}));
|
||||
|
||||
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
|
||||
} catch (error) {
|
||||
console.error('Erro na análise:', error);
|
||||
toast.error('Erro ao analisar documento com IA');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.titulo || !formData.categoria || !formData.disciplina || !formData.conteudo) {
|
||||
toast.error('Por favor, preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
numero_paginas: formData.numero_paginas ? parseInt(formData.numero_paginas) : null
|
||||
};
|
||||
|
||||
await onSave(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{catalogo ? 'Editar Documento' : 'Novo Documento'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Seção de Upload de Arquivos - apenas para novos documentos */}
|
||||
{!catalogo && (
|
||||
<div className="p-4 bg-slate-50 rounded-lg border">
|
||||
<FileUploadSection
|
||||
onFilesAnalyzed={handleFilesAnalyzed}
|
||||
onFilesUploaded={handleFilesUploaded}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titulo">Título *</Label>
|
||||
<Input
|
||||
id="titulo"
|
||||
value={formData.titulo}
|
||||
onChange={(e) => handleInputChange('titulo', e.target.value)}
|
||||
placeholder="Digite o título do documento"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numero_paginas">Número de Páginas</Label>
|
||||
<Input
|
||||
id="numero_paginas"
|
||||
type="number"
|
||||
value={formData.numero_paginas}
|
||||
onChange={(e) => handleInputChange('numero_paginas', e.target.value)}
|
||||
placeholder="Ex: 25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoria">Categoria *</Label>
|
||||
<Select
|
||||
value={formData.categoria}
|
||||
onValueChange={(value) => handleInputChange('categoria', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categorias.map((categoria) => (
|
||||
<SelectItem key={categoria} value={categoria}>
|
||||
{categoria}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disciplina">Disciplina *</Label>
|
||||
<Select
|
||||
value={formData.disciplina}
|
||||
onValueChange={(value) => handleInputChange('disciplina', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma disciplina" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{disciplinas.map((disciplina) => (
|
||||
<SelectItem key={disciplina} value={disciplina}>
|
||||
{disciplina}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conteudo">Conteúdo do Documento *</Label>
|
||||
<Textarea
|
||||
id="conteudo"
|
||||
value={formData.conteudo}
|
||||
onChange={(e) => handleInputChange('conteudo', e.target.value)}
|
||||
placeholder="Cole o conteúdo do documento aqui..."
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
{!catalogo && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={analyzeWithAI}
|
||||
disabled={isAnalyzing || !formData.conteudo.trim()}
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Palavras-chave</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder="Adicionar palavra-chave"
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addKeyword())}
|
||||
/>
|
||||
<Button type="button" onClick={addKeyword} variant="outline">
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.palavras_chave.map((keyword, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center gap-1">
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKeyword(keyword)}
|
||||
className="ml-1 hover:text-red-400"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700">
|
||||
{catalogo ? 'Atualizar' : 'Salvar'} Documento
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Edit, Trash2, FileText, Calendar, Hash, Eye } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface CatalogoPreviewsProps {
|
||||
catalogos: Catalogo[];
|
||||
onEdit: (catalogo: Catalogo) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onView: (catalogo: Catalogo) => void;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export function CatalogoPreviews({ catalogos, onEdit, onDelete, onView, canModify }: CatalogoPreviewsProps) {
|
||||
if (catalogos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p>Nenhum documento encontrado</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{catalogos.map((catalogo) => (
|
||||
<Card
|
||||
key={catalogo.id}
|
||||
className="bg-white border-slate-300 shadow-sm hover:shadow-md transition-colors cursor-pointer
|
||||
dark:bg-slate-800/30 dark:border-slate-700 dark:hover:bg-slate-800/50"
|
||||
onClick={() => onView(catalogo)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<CardTitle className="text-slate-900 dark:text-white text-lg line-clamp-2" title={catalogo.titulo}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 flex-shrink-0" />
|
||||
{catalogo.titulo}
|
||||
</div>
|
||||
</CardTitle>
|
||||
{canModify && (
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onView(catalogo);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
|
||||
title="Visualizar documento"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(catalogo);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(catalogo.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-slate-600 dark:text-slate-400 text-sm line-clamp-3">
|
||||
{catalogo.conteudo.substring(0, 150)}...
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>{catalogo.numero_paginas || 'N/A'} pág.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{format(new Date(catalogo.created_at), 'dd/MM/yy', { locale: ptBR })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave.slice(0, 4).map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
{catalogo.palavras_chave.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
+{catalogo.palavras_chave.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalogo.arquivo_urls && catalogo.arquivo_urls.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>{catalogo.arquivo_urls.length} documento(s) disponível(eis)</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, X, Grid, List } from 'lucide-react';
|
||||
|
||||
interface CatalogosFiltersProps {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
categoriaFilter: string;
|
||||
setCategoriaFilter: (value: string) => void;
|
||||
disciplinaFilter: string;
|
||||
setDisciplinaFilter: (value: string) => void;
|
||||
onClearFilters: () => void;
|
||||
viewMode: 'table' | 'preview';
|
||||
onViewModeChange: (mode: 'table' | 'preview') => void;
|
||||
}
|
||||
|
||||
const categorias = [
|
||||
'Aço',
|
||||
'Adesivo',
|
||||
'Tintas',
|
||||
'Parafusos',
|
||||
'Soldas',
|
||||
'Estruturas',
|
||||
'Materiais'
|
||||
];
|
||||
|
||||
const disciplinas = [
|
||||
'Engenharia Civil',
|
||||
'Engenharia Mecânica',
|
||||
'Arquitetura',
|
||||
'Construção',
|
||||
'Fabricação',
|
||||
'Montagem'
|
||||
];
|
||||
|
||||
export function CatalogosFilters({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
categoriaFilter,
|
||||
setCategoriaFilter,
|
||||
disciplinaFilter,
|
||||
setDisciplinaFilter,
|
||||
onClearFilters,
|
||||
viewMode,
|
||||
onViewModeChange
|
||||
}: CatalogosFiltersProps) {
|
||||
const hasActiveFilters = searchTerm || (categoriaFilter && categoriaFilter !== 'all') || (disciplinaFilter && disciplinaFilter !== 'all');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Buscar por título, conteúdo, palavras-chave..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={categoriaFilter} onValueChange={setCategoriaFilter}>
|
||||
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
|
||||
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Categorias</SelectItem>
|
||||
{categorias.map((categoria) => (
|
||||
<SelectItem key={categoria} value={categoria} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
|
||||
{categoria}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={disciplinaFilter} onValueChange={setDisciplinaFilter}>
|
||||
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<SelectValue placeholder="Disciplina" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
|
||||
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Disciplinas</SelectItem>
|
||||
{disciplinas.map((disciplina) => (
|
||||
<SelectItem key={disciplina} value={disciplina} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
|
||||
{disciplina}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'table' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('table')}
|
||||
className={viewMode === 'table' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'preview' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
className={viewMode === 'preview' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClearFilters}
|
||||
size="sm"
|
||||
className="h-10 bg-white border-slate-300 text-slate-700 hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash2, Eye, FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface CatalogosTableProps {
|
||||
catalogos: Catalogo[];
|
||||
onEdit: (catalogo: Catalogo) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onView: (catalogo: Catalogo) => void;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export function CatalogosTable({ catalogos, onEdit, onDelete, onView, canModify }: CatalogosTableProps) {
|
||||
if (catalogos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Eye className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p>Nenhum documento encontrado</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table disableOverflow>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 dark:bg-transparent">
|
||||
<TableHead className="text-slate-700 dark:text-white">Título</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Categoria</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Disciplina</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Páginas</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Data de Inclusão</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Palavras-chave</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogos.map((catalogo) => (
|
||||
<TableRow key={catalogo.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
|
||||
<TableCell className="text-slate-900 dark:text-white font-medium max-w-[200px]">
|
||||
<div
|
||||
className="truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={catalogo.titulo}
|
||||
onClick={() => onView(catalogo)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 flex-shrink-0" />
|
||||
{catalogo.titulo}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-700 dark:text-slate-300">
|
||||
{catalogo.numero_paginas || 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-700 dark:text-slate-300">
|
||||
{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave?.slice(0, 3).map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
+{catalogo.palavras_chave.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onView(catalogo)}
|
||||
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
|
||||
title="Visualizar documento"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{canModify && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(catalogo)}
|
||||
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(catalogo.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
|
||||
// Top-level imports
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Download, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, FileText, Image, ExternalLink, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface DocumentViewerProps {
|
||||
catalogo: Catalogo | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// DocumentViewer component
|
||||
export function DocumentViewer({ catalogo, isOpen, onClose }: DocumentViewerProps) {
|
||||
// TODOS OS HOOKS DEVEM ESTAR NO TOPO - ANTES DE QUALQUER RETURN CONDICIONAL
|
||||
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// useEffect também deve estar no topo
|
||||
useEffect(() => {
|
||||
const onFullScreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFullScreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
|
||||
}, []);
|
||||
|
||||
// Funções de handler
|
||||
const handlePrevious = () => {
|
||||
if (currentDocIndex > 0) {
|
||||
setCurrentDocIndex(currentDocIndex - 1);
|
||||
setZoomLevel(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (catalogo?.arquivo_urls && currentDocIndex < catalogo.arquivo_urls.length - 1) {
|
||||
setCurrentDocIndex(currentDocIndex + 1);
|
||||
setZoomLevel(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (zoomLevel < 3) {
|
||||
setZoomLevel(zoomLevel + 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (zoomLevel > 0.5) {
|
||||
setZoomLevel(zoomLevel - 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
|
||||
if (currentDoc) {
|
||||
const link = document.createElement('a');
|
||||
link.href = currentDoc;
|
||||
link.download = '';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
|
||||
if (currentDoc) {
|
||||
window.open(currentDoc, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFullscreen = async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await viewerRef.current?.requestFullscreen?.();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
await document.exitFullscreen?.();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao alternar tela cheia:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setCurrentDocIndex(0);
|
||||
setZoomLevel(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// AGORA SIM podemos fazer o return condicional - APÓS todos os hooks
|
||||
if (!catalogo || !catalogo.arquivo_urls || catalogo.arquivo_urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentDoc = catalogo.arquivo_urls[currentDocIndex];
|
||||
const isImage = currentDoc?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(currentDoc || '');
|
||||
const isPdf = currentDoc?.includes('pdf') || /\.pdf$/i.test(currentDoc || '');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) resetAndClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="
|
||||
w-[95vw] max-w-[95vw] h-[90vh]
|
||||
bg-white text-slate-900 border-slate-300
|
||||
dark:bg-slate-900 dark:text-white dark:border-slate-700
|
||||
"
|
||||
>
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex-1">
|
||||
<DialogTitle className="text-xl mb-2">{catalogo.titulo}</DialogTitle>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-slate-100 text-slate-700 border border-slate-300 dark:bg-blue-900 dark:text-blue-100 dark:border-slate-700">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</div>
|
||||
<span>{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}</span>
|
||||
{catalogo.numero_paginas && <span>{catalogo.numero_paginas} páginas</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="mr-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
{currentDocIndex + 1} de {catalogo.arquivo_urls.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImage && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomOut} disabled={zoomLevel <= 0.5} className="h-8 w-8 p-0">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 px-2">{Math.round(zoomLevel * 100)}%</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomIn} disabled={zoomLevel >= 3} className="h-8 w-8 p-0">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="h-8 w-8 p-0" title="Abrir em nova aba">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleToggleFullscreen} className="h-8 w-8 p-0" title="Tela cheia">
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleDownload} className="h-8 w-8 p-0" title="Baixar">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetAndClose} className="h-8 w-8 p-0" title="Fechar">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden pt-3">
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" onClick={handlePrevious} disabled={currentDocIndex === 0} className="h-10 w-10 p-0">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={viewerRef}
|
||||
className="
|
||||
flex-1 flex items-center justify-center overflow-auto
|
||||
bg-slate-50 border border-slate-200/80 rounded-lg mx-2
|
||||
dark:bg-slate-800/30 dark:border-slate-700
|
||||
"
|
||||
>
|
||||
{isPdf ? (
|
||||
<iframe
|
||||
src={currentDoc}
|
||||
className="w-full h-full min-h-[65vh] rounded-lg"
|
||||
title={`Documento ${currentDocIndex + 1}`}
|
||||
/>
|
||||
) : isImage ? (
|
||||
<img
|
||||
src={currentDoc}
|
||||
alt={`Documento ${currentDocIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain rounded-lg transition-transform duration-200"
|
||||
style={{ transform: `scale(${zoomLevel})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-slate-500 dark:text-slate-400 gap-4">
|
||||
<FileText className="h-12 w-12" />
|
||||
<p>Tipo de documento não suportado para visualização</p>
|
||||
<Button onClick={handleDownload} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Baixar Documento
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" onClick={handleNext} disabled={currentDocIndex === catalogo.arquivo_urls.length - 1} className="h-10 w-10 p-0">
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{catalogo.arquivo_urls.map((url, index) => {
|
||||
const isCurrentImage = url?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(url || '');
|
||||
const isCurrentPdf = url?.includes('pdf') || /\.pdf$/i.test(url || '');
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => { setCurrentDocIndex(index); setZoomLevel(1); }}
|
||||
className={`flex-shrink-0 w-16 h-16 rounded-lg border-2 overflow-hidden transition-colors ${
|
||||
index === currentDocIndex ? 'border-blue-500' : 'border-slate-300 hover:border-slate-400 dark:border-slate-600 dark:hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isCurrentImage ? (
|
||||
<img src={url} alt={`Miniatura ${index + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
{isCurrentPdf ? (
|
||||
<FileText className="h-6 w-6 text-slate-500 dark:text-slate-400" />
|
||||
) : (
|
||||
<Image className="h-6 w-6 text-slate-500 dark:text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-slate-700 dark:text-slate-300 text-sm line-clamp-3">{catalogo.conteudo}</p>
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave.map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Upload, X, FileText, Image, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface FileUploadSectionProps {
|
||||
onFilesAnalyzed: (analysisResult: {
|
||||
titulo?: string;
|
||||
categoria?: string;
|
||||
disciplina?: string;
|
||||
conteudo?: string;
|
||||
palavras_chave?: string[];
|
||||
}) => void;
|
||||
onFilesUploaded: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
file: File;
|
||||
url?: string;
|
||||
uploading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FileUploadSection({ onFilesAnalyzed, onFilesUploaded }: FileUploadSectionProps) {
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
|
||||
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
|
||||
// Validar tipos de arquivo
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/tiff',
|
||||
'image/bmp'
|
||||
];
|
||||
|
||||
const validFiles = files.filter(file => {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error(`Tipo de arquivo não permitido: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||||
toast.error(`Arquivo muito grande: ${file.name} (máximo 10MB)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
const newFiles = validFiles.map(file => ({
|
||||
file,
|
||||
uploading: false,
|
||||
error: undefined
|
||||
}));
|
||||
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
}
|
||||
|
||||
// Limpar o input
|
||||
event.target.value = '';
|
||||
}, []);
|
||||
|
||||
const uploadFile = async (fileData: UploadedFile, index: number) => {
|
||||
try {
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: true, error: undefined } : f
|
||||
));
|
||||
|
||||
const fileName = `${Date.now()}-${fileData.file.name}`;
|
||||
const { data, error } = await supabase.storage
|
||||
.from('catalogo-documents')
|
||||
.upload(fileName, fileData.file);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('catalogo-documents')
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: false, url: publicUrl } : f
|
||||
));
|
||||
|
||||
return publicUrl;
|
||||
} catch (error) {
|
||||
console.error('Erro no upload:', error);
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: false, error: 'Erro no upload' } : f
|
||||
));
|
||||
toast.error('Erro no upload do arquivo');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAllFiles = async () => {
|
||||
const uploadPromises = uploadedFiles.map((fileData, index) => {
|
||||
if (!fileData.url && !fileData.uploading) {
|
||||
return uploadFile(fileData, index);
|
||||
}
|
||||
return Promise.resolve(fileData.url);
|
||||
});
|
||||
|
||||
const urls = await Promise.all(uploadPromises);
|
||||
const validUrls = urls.filter(url => url !== null) as string[];
|
||||
onFilesUploaded(validUrls);
|
||||
return validUrls;
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const analyzeFiles = async () => {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error('Adicione pelo menos um arquivo para analisar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
|
||||
try {
|
||||
// Primeiro fazer upload de todos os arquivos
|
||||
const urls = await uploadAllFiles();
|
||||
|
||||
if (urls.length === 0) {
|
||||
toast.error('Nenhum arquivo foi carregado com sucesso');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simular análise com IA baseada nos arquivos
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Análise baseada nos nomes e tipos de arquivo
|
||||
const fileNames = uploadedFiles.map(f => f.file.name.toLowerCase());
|
||||
const hasImages = uploadedFiles.some(f => f.file.type.startsWith('image/'));
|
||||
const hasPdf = uploadedFiles.some(f => f.file.type === 'application/pdf');
|
||||
|
||||
let suggestedCategoria = '';
|
||||
let suggestedDisciplina = '';
|
||||
let suggestedTitulo = '';
|
||||
let suggestedKeywords: string[] = [];
|
||||
let suggestedContent = '';
|
||||
|
||||
// Lógica de categorização baseada em nomes de arquivo
|
||||
const content = fileNames.join(' ');
|
||||
|
||||
if (content.includes('aço') || content.includes('steel')) {
|
||||
suggestedCategoria = 'Aço';
|
||||
suggestedKeywords.push('aço', 'steel');
|
||||
} else if (content.includes('tinta') || content.includes('pintura')) {
|
||||
suggestedCategoria = 'Tintas';
|
||||
suggestedKeywords.push('tinta', 'pintura');
|
||||
} else if (content.includes('parafuso') || content.includes('fixação')) {
|
||||
suggestedCategoria = 'Parafusos';
|
||||
suggestedKeywords.push('parafuso', 'fixação');
|
||||
} else {
|
||||
suggestedCategoria = 'Materiais';
|
||||
}
|
||||
|
||||
if (content.includes('estrutura') || content.includes('construção')) {
|
||||
suggestedDisciplina = 'Engenharia Civil';
|
||||
suggestedKeywords.push('estrutura', 'construção');
|
||||
} else if (content.includes('mecânica') || content.includes('fabricação')) {
|
||||
suggestedDisciplina = 'Engenharia Mecânica';
|
||||
suggestedKeywords.push('mecânica', 'fabricação');
|
||||
} else {
|
||||
suggestedDisciplina = 'Construção';
|
||||
}
|
||||
|
||||
// Gerar título baseado no primeiro arquivo
|
||||
if (uploadedFiles.length > 0) {
|
||||
const firstFileName = uploadedFiles[0].file.name.replace(/\.[^/.]+$/, '');
|
||||
suggestedTitulo = firstFileName.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
// Gerar conteúdo baseado nos tipos de arquivo
|
||||
suggestedContent = `Documento ${hasPdf ? 'PDF' : 'de imagens'} contendo informações técnicas sobre ${suggestedCategoria.toLowerCase()}.`;
|
||||
|
||||
if (hasImages) {
|
||||
suggestedContent += ` Inclui ${uploadedFiles.filter(f => f.file.type.startsWith('image/')).length} imagem(ns) técnica(s).`;
|
||||
}
|
||||
|
||||
if (hasPdf) {
|
||||
suggestedContent += ` Documento PDF com especificações e detalhes técnicos.`;
|
||||
}
|
||||
|
||||
// Adicionar palavras-chave relacionadas ao tipo de arquivo
|
||||
if (hasPdf) suggestedKeywords.push('pdf', 'documento');
|
||||
if (hasImages) suggestedKeywords.push('imagem', 'visual');
|
||||
|
||||
onFilesAnalyzed({
|
||||
titulo: suggestedTitulo,
|
||||
categoria: suggestedCategoria,
|
||||
disciplina: suggestedDisciplina,
|
||||
conteudo: suggestedContent,
|
||||
palavras_chave: [...new Set(suggestedKeywords)]
|
||||
});
|
||||
|
||||
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
|
||||
} catch (error) {
|
||||
console.error('Erro na análise:', error);
|
||||
toast.error('Erro ao analisar documentos');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (file: File) => {
|
||||
return file.type === 'application/pdf' ? FileText : Image;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file-upload">Upload de Documentos</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.tiff,.bmp"
|
||||
onChange={handleFileSelect}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={analyzeFiles}
|
||||
disabled={isAnalyzing || uploadedFiles.length === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tipos aceitos: PDF, JPG, PNG, GIF, WebP, TIFF, BMP (máximo 10MB cada)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Arquivos Selecionados</Label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{uploadedFiles.map((fileData, index) => {
|
||||
const Icon = getFileIcon(fileData.file);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-slate-50 rounded border"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Icon className="w-4 h-4 text-slate-600 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{fileData.file.name}</span>
|
||||
<span className="text-xs text-slate-500 flex-shrink-0">
|
||||
({(fileData.file.size / (1024 * 1024)).toFixed(1)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{fileData.uploading && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
{fileData.url && (
|
||||
<span className="text-xs text-green-600">✓</span>
|
||||
)}
|
||||
{fileData.error && (
|
||||
<span className="text-xs text-red-600">✗</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user