🚀 Initial commit: Versão atual do TrackSteel APP

This commit is contained in:
2026-03-18 21:17:53 +00:00
commit bde410c9ad
633 changed files with 108150 additions and 0 deletions
+350
View File
@@ -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>
);
}
+160
View File
@@ -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>
);
}
+276
View File
@@ -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>
);
}