🚀 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
+351
View File
@@ -0,0 +1,351 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Trash2, Edit, Key, Star, Plus, TestTube2, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useApiKeys, ApiKey } from '@/hooks/useApiKeys';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export const ApiKeysManager = () => {
const { apiKeys, loading, saveApiKey, setPrimaryKey, deleteApiKey } = useApiKeys();
const [showDialog, setShowDialog] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
const [formData, setFormData] = useState({ name: '', key: '' });
const [testingKeys, setTestingKeys] = useState<Set<string>>(new Set());
const [keyTestResults, setKeyTestResults] = useState<Map<string, boolean>>(new Map());
const handleEdit = (apiKey: ApiKey) => {
setEditingKey(apiKey);
setFormData({ name: apiKey.name, key: apiKey.key });
setShowDialog(true);
};
const handleAdd = () => {
setEditingKey(null);
setFormData({ name: '', key: '' });
setShowDialog(true);
};
const handleSave = async () => {
if (!formData.name.trim() || !formData.key.trim()) {
return;
}
await saveApiKey({
id: editingKey?.id,
name: formData.name,
key: formData.key
});
setShowDialog(false);
setFormData({ name: '', key: '' });
setEditingKey(null);
};
const handleClose = () => {
setShowDialog(false);
setFormData({ name: '', key: '' });
setEditingKey(null);
};
const testApiKey = async (apiKey: ApiKey) => {
setTestingKeys(prev => new Set([...prev, apiKey.id]));
try {
// Teste básico para OpenAI/Gemini APIs
const response = await fetch('https://api.openai.com/v1/models', {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey.key}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
toast.success(`Chave "${apiKey.name}" validada com sucesso!`);
} else if (response.status === 401 || response.status === 403) {
// Se falhar com OpenAI, tenta com Gemini
const geminiResponse = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey.key}`, {
method: 'GET',
});
if (geminiResponse.ok) {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
toast.success(`Chave "${apiKey.name}" validada com sucesso (Gemini)!`);
} else {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
toast.error(`Chave "${apiKey.name}" é inválida ou não tem permissões adequadas.`);
}
} else {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
toast.error(`Erro ao testar chave "${apiKey.name}": ${response.status}`);
}
} catch (error) {
console.error('Erro ao testar chave API:', error);
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
toast.error(`Erro de conexão ao testar chave "${apiKey.name}"`);
} finally {
setTestingKeys(prev => {
const newSet = new Set(prev);
newSet.delete(apiKey.id);
return newSet;
});
}
};
const getTestStatusIcon = (keyId: string) => {
const isValid = keyTestResults.get(keyId);
if (isValid === true) {
return <CheckCircle className="w-4 h-4 text-green-500" />;
} else if (isValid === false) {
return <XCircle className="w-4 h-4 text-red-500" />;
}
return null;
};
const canAddMore = apiKeys.length < 3;
if (loading) {
return (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-card-foreground flex items-center gap-2">
<Key className="w-5 h-5" />
Gerenciamento de Chaves API
</CardTitle>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-2">
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-card-foreground flex items-center gap-2">
<Key className="w-5 h-5" />
Gerenciamento de Chaves API
</CardTitle>
{canAddMore && (
<Button
onClick={handleAdd}
size="sm"
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="w-4 h-4 mr-2" />
Adicionar
</Button>
)}
</CardHeader>
<CardContent>
<div className="space-y-4">
{apiKeys.length === 0 ? (
<div className="text-center py-8">
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">Nenhuma chave API configurada</p>
<p className="text-muted-foreground text-sm">
Adicione até 3 chaves para redundância automática
</p>
</div>
) : (
apiKeys.map((apiKey) => (
<div
key={apiKey.id}
className="border border-border rounded-lg p-2 sm:p-3 space-y-2 bg-card"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-card-foreground font-medium text-sm">{apiKey.name}</h4>
{apiKey.is_primary ? (
<Badge variant="default" className="bg-primary text-primary-foreground text-xs">
<Star className="w-3 h-3 mr-1" />
Principal
</Badge>
) : (
<Badge variant="secondary" className="text-xs">Secundária</Badge>
)}
{getTestStatusIcon(apiKey.id)}
</div>
<div className="flex items-center gap-1 flex-wrap">
{testingKeys.has(apiKey.id) ? (
<Button
size="sm"
variant="outline"
disabled
className="text-xs h-6 px-2"
>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
<span className="hidden sm:inline">Testando...</span>
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => testApiKey(apiKey)}
className="text-xs hover:bg-accent hover:text-accent-foreground h-6 px-2"
>
<TestTube2 className="w-3 h-3 mr-1" />
<span className="hidden sm:inline">Testar</span>
</Button>
)}
{!apiKey.is_primary && (
<Button
size="sm"
variant="outline"
onClick={() => setPrimaryKey(apiKey.id)}
className="text-xs h-6 px-2 hidden sm:inline-flex"
>
Definir como Principal
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(apiKey)}
className="h-6 w-6 p-0 hover:bg-accent hover:text-accent-foreground"
>
<Edit className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(apiKey.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10 h-6 w-6 p-0"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground font-mono bg-muted/50 p-2 rounded">
{apiKey.key.substring(0, 20)}...
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 text-xs text-muted-foreground">
<span>Criada: {new Date(apiKey.created_at).toLocaleDateString('pt-BR')}</span>
{keyTestResults.has(apiKey.id) && (
<span className={`flex items-center gap-1 ${
keyTestResults.get(apiKey.id) ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{keyTestResults.get(apiKey.id) ? (
<>
<CheckCircle className="w-3 h-3" />
Válida
</>
) : (
<>
<XCircle className="w-3 h-3" />
Inválida
</>
)}
</span>
)}
</div>
{/* Mobile: Show primary button if not primary */}
{!apiKey.is_primary && (
<div className="sm:hidden">
<Button
size="sm"
variant="outline"
onClick={() => setPrimaryKey(apiKey.id)}
className="text-xs h-6 w-full"
>
Definir como Principal
</Button>
</div>
)}
</div>
))
)}
{apiKeys.length > 0 && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg border border-border">
<p className="text-sm text-card-foreground mb-2">
<strong>Sistema de Fallback:</strong>
</p>
<p className="text-xs text-muted-foreground mb-2">
O sistema usa automaticamente a chave principal. Em caso de falha (401/403/timeout),
tenta as chaves secundárias em sequência até encontrar uma válida.
</p>
<p className="text-xs text-muted-foreground">
<strong>Teste de Validação:</strong> Verifica se a chave é válida testando conexão com OpenAI ou Gemini APIs.
</p>
</div>
)}
</div>
</CardContent>
</Card>
<Dialog open={showDialog} onOpenChange={handleClose}>
<DialogContent className="bg-card border-border">
<DialogHeader>
<DialogTitle className="text-card-foreground">
{editingKey ? 'Editar Chave API' : 'Adicionar Chave API'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{editingKey
? 'Modifique os dados da chave API existente.'
: 'Adicione uma nova chave API ao sistema.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-card-foreground">Nome da Chave</Label>
<Input
id="name"
placeholder="Ex: OpenAI Principal, Gemini Backup, etc."
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="bg-background border-input text-foreground"
/>
</div>
<div>
<Label htmlFor="key" className="text-card-foreground">Chave API</Label>
<Input
id="key"
type="password"
placeholder="Insira a chave API"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
className="bg-background border-input text-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
Suporta chaves OpenAI, Gemini e outras APIs compatíveis
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button onClick={handleSave} className="bg-primary hover:bg-primary/90 text-primary-foreground">
{editingKey ? 'Salvar' : 'Adicionar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
+89
View File
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Database, Trash2 } from 'lucide-react';
import { CleanupDuplicatesModal } from './CleanupDuplicatesModal';
export const ApontamentoMassa: React.FC = () => {
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Gestão de Apontamentos em Massa</h2>
<p className="text-muted-foreground">
Ferramentas para análise e correção de inconsistências nos apontamentos de produção.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Limpeza de Duplicatas
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Identifica e remove apontamentos duplicados ou em excesso por OF.
O sistema analisa peças que foram apontadas múltiplas vezes para o mesmo processo
e remove os registros mais recentes, mantendo apenas a quantidade correta.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<h4 className="font-medium text-yellow-800 mb-2">Como funciona:</h4>
<ul className="text-sm text-yellow-700 space-y-1">
<li>• Agrupa apontamentos por OF + Marca + Fase + Processo</li>
<li>• Identifica quando o total apontado excede a quantidade da peça</li>
<li>• Remove apontamentos mais recentes, mantendo os mais antigos</li>
<li>• Preserva a integridade dos dados de produção</li>
</ul>
</div>
<Button
onClick={() => setIsCleanupModalOpen(true)}
className="w-full flex items-center gap-2"
variant="destructive"
>
<Trash2 className="w-4 h-4" />
Analisar e Limpar Duplicatas
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5 text-blue-500" />
Outras Ferramentas
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Ferramentas adicionais para gestão de apontamentos em massa estarão disponíveis em breve.
</p>
<div className="space-y-2">
<Button disabled className="w-full" variant="outline">
Recalcular Totais por OF (Em breve)
</Button>
<Button disabled className="w-full" variant="outline">
Validar Sequência de Processos (Em breve)
</Button>
<Button disabled className="w-full" variant="outline">
Relatório de Inconsistências (Em breve)
</Button>
</div>
</CardContent>
</Card>
</div>
<CleanupDuplicatesModal
isOpen={isCleanupModalOpen}
onClose={() => setIsCleanupModalOpen(false)}
/>
</div>
);
};
+312
View File
@@ -0,0 +1,312 @@
import React, { useState, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Download, Upload, Clock, CheckCircle, XCircle, Database, FileText, AlertTriangle } from 'lucide-react';
import { useBackupManager } from '@/hooks/useBackupManager';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
const BackupManager = () => {
const { backupLogs, logsLoading, isBackingUp, isRestoring, createBackup, restoreBackup } = useBackupManager();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleCreateBackup = () => {
createBackup();
};
const handleRestore = () => {
if (selectedFile) {
restoreBackup(selectedFile);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed':
return <XCircle className="h-4 w-4 text-red-500" />;
case 'in_progress':
return <Clock className="h-4 w-4 text-yellow-500" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <Badge variant="secondary" className="bg-green-100 text-green-800">Concluído</Badge>;
case 'failed':
return <Badge variant="destructive">Falhou</Badge>;
case 'in_progress':
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800">Em Progresso</Badge>;
default:
return null;
}
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return 'N/A';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-6">
{/* Seção de Criação de Backup */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Criar Backup
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-3">
<Database className="h-6 w-6 text-blue-600" />
<div>
<p className="font-medium text-blue-900">Backup Completo do Banco de Dados</p>
<p className="text-sm text-blue-700">Exporta todas as tabelas e dados em formato ZIP</p>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={isBackingUp}
className="border-blue-300 text-blue-700 hover:bg-blue-100"
>
{isBackingUp ? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Criando...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Criar Backup
</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar Criação de Backup</AlertDialogTitle>
<AlertDialogDescription>
Isso irá criar um backup completo de todas as tabelas do banco de dados.
O processo pode demorar alguns minutos dependendo do tamanho dos dados.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleCreateBackup}>
Criar Backup
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{isBackingUp && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4 animate-spin" />
Criando backup...
</div>
<Progress value={50} className="h-2" />
</div>
)}
</CardContent>
</Card>
{/* Seção de Restauração de Backup */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Restaurar Backup
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
<p className="font-medium text-yellow-900">Atenção!</p>
</div>
<p className="text-sm text-yellow-800">
A restauração irá <strong>substituir completamente</strong> todos os dados atuais do banco.
Esta operação não pode ser desfeita. Certifique-se de ter um backup atual antes de prosseguir.
</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-2">Selecionar Arquivo de Backup</label>
<input
ref={fileInputRef}
type="file"
accept=".zip,.json"
onChange={handleFileSelect}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
</div>
{selectedFile && (
<div className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-gray-600" />
<span className="text-sm font-medium">{selectedFile.name}</span>
<span className="text-xs text-gray-500">({formatFileSize(selectedFile.size)})</span>
</div>
</div>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={!selectedFile || isRestoring}
className="w-full"
>
{isRestoring ? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Restaurando...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Restaurar Backup
</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-red-600">Confirmar Restauração</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-2">
<p>Esta ação irá:</p>
<ul className="list-disc list-inside text-sm space-y-1">
<li>Apagar TODOS os dados atuais do banco</li>
<li>Restaurar os dados do arquivo: <strong>{selectedFile?.name}</strong></li>
<li>Esta operação NÃO pode ser desfeita</li>
</ul>
<p className="text-red-600 font-medium mt-3">Tem certeza de que deseja continuar?</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRestore} className="bg-red-600 hover:bg-red-700">
Sim, Restaurar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{isRestoring && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4 animate-spin" />
Restaurando backup...
</div>
<Progress value={50} className="h-2" />
</div>
)}
</CardContent>
</Card>
{/* Histórico de Operações */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Histórico de Operações
</CardTitle>
</CardHeader>
<CardContent>
{logsLoading ? (
<div className="text-center py-8">
<Clock className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">Carregando histórico...</p>
</div>
) : backupLogs && backupLogs.length > 0 ? (
<div className="space-y-4">
{backupLogs.map((log) => (
<div key={log.id} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
{getStatusIcon(log.status)}
<div>
<p className="font-medium">
{log.operation_type === 'backup' ? 'Backup' : 'Restauração'}
</p>
<p className="text-sm text-muted-foreground">{log.file_name}</p>
</div>
</div>
{getStatusBadge(log.status)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Iniciado em:</span>
<p>{format(new Date(log.started_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
</div>
{log.completed_at && (
<div>
<span className="text-muted-foreground">Concluído em:</span>
<p>{format(new Date(log.completed_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
</div>
)}
<div>
<span className="text-muted-foreground">Tamanho:</span>
<p>{formatFileSize(log.file_size)}</p>
</div>
<div>
<span className="text-muted-foreground">Tabelas/Registros:</span>
<p>{log.tables_count || 0} / {log.records_count || 0}</p>
</div>
</div>
{log.error_message && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded">
<p className="text-sm text-red-800">{log.error_message}</p>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Database className="h-8 w-8 mx-auto mb-4 opacity-50" />
<p>Nenhuma operação de backup encontrada</p>
</div>
)}
</CardContent>
</Card>
</div>
);
};
export default BackupManager;
@@ -0,0 +1,305 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Search, AlertTriangle, Trash2 } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
interface CleanupDuplicatesModalProps {
isOpen: boolean;
onClose: () => void;
}
interface DuplicateGroup {
chave_agrupamento: string;
of_number: string;
marca: string;
etapa_fase: string;
processo_nome: string;
quantidade_total_peca: number;
apontamentos: Array<{
id: string;
data_apontamento: string;
created_at: string;
quantidade_produzida: number;
tipo_apontamento: string;
}>;
total_apontado: number;
excesso: number;
}
export const CleanupDuplicatesModal: React.FC<CleanupDuplicatesModalProps> = ({
isOpen,
onClose,
}) => {
const [ofNumber, setOfNumber] = useState('');
const [duplicatesData, setDuplicatesData] = useState<{
duplicatesFound: boolean;
details: DuplicateGroup[];
totalGroups: number;
groupsWithDuplicates: number;
} | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isCleaning, setIsCleaning] = useState(false);
const analyzeOF = async () => {
if (!ofNumber.trim()) {
toast.error('Por favor, informe o número da OF');
return;
}
setIsAnalyzing(true);
try {
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
body: {
of_number: ofNumber.trim(),
action: 'analyze' // Apenas analisar, não executar limpeza
}
});
if (error) throw error;
setDuplicatesData({
duplicatesFound: data.groupsWithDuplicates > 0,
details: data.details || [],
totalGroups: data.totalGroups || 0,
groupsWithDuplicates: data.groupsWithDuplicates || 0
});
if (data.groupsWithDuplicates === 0) {
toast.success('Nenhuma duplicata encontrada para esta OF!');
} else {
toast.info(`Encontradas ${data.groupsWithDuplicates} duplicatas para análise`);
}
} catch (error) {
console.error('Erro ao analisar duplicatas:', error);
toast.error('Erro ao analisar duplicatas');
} finally {
setIsAnalyzing(false);
}
};
const executeCleaning = async () => {
setIsCleaning(true);
try {
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
body: {
of_number: ofNumber.trim(),
action: 'execute' // Executar limpeza
}
});
if (error) throw error;
toast.success(`Limpeza concluída! ${data.duplicatesRemoved} duplicatas removidas.`);
// Resetar dados após limpeza
setDuplicatesData(null);
setOfNumber('');
} catch (error) {
console.error('Erro ao executar limpeza:', error);
toast.error('Erro ao executar limpeza de duplicatas');
} finally {
setIsCleaning(false);
}
};
const handleClose = () => {
setDuplicatesData(null);
setOfNumber('');
onClose();
};
const groupedByPhase = duplicatesData?.details.reduce((acc, group) => {
const phase = group.etapa_fase;
if (!acc[phase]) acc[phase] = [];
acc[phase].push(group);
return acc;
}, {} as Record<string, DuplicateGroup[]>) || {};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Limpeza de Duplicatas de Apontamentos
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Input para OF */}
<div className="flex gap-2">
<Input
placeholder="Digite o número da OF (ex: B117)"
value={ofNumber}
onChange={(e) => setOfNumber(e.target.value.toUpperCase())}
className="flex-1"
/>
<Button
onClick={analyzeOF}
disabled={isAnalyzing || !ofNumber.trim()}
className="flex items-center gap-2"
>
{isAnalyzing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
Analisar
</Button>
</div>
{/* Resultados da análise */}
{duplicatesData && (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">
Resumo da Análise - OF {ofNumber}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{duplicatesData.totalGroups}
</div>
<div className="text-sm text-muted-foreground">
Grupos Analisados
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{duplicatesData.groupsWithDuplicates}
</div>
<div className="text-sm text-muted-foreground">
Com Duplicatas
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{duplicatesData.details.reduce((sum, g) => sum + g.apontamentos.length - 1, 0)}
</div>
<div className="text-sm text-muted-foreground">
Registros a Remover
</div>
</div>
</div>
</CardContent>
</Card>
{/* Detalhes por fase */}
{duplicatesData.duplicatesFound && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Duplicatas Encontradas por Fase:</h3>
{Object.entries(groupedByPhase).map(([phase, groups]) => (
<Card key={phase}>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Fase {phase}
<Badge variant="destructive">
{groups.length} duplicatas
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{groups.map((group, index) => (
<div key={index} className="border rounded-lg p-3 bg-muted/30">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-medium">
Marca: {group.marca} | Processo: {group.processo_nome}
</h4>
<div className="text-sm text-muted-foreground">
Quantidade da Peça: {group.quantidade_total_peca} |
Total Apontado: {group.total_apontado}
{group.excesso > 0 && (
<span className="text-red-600 font-medium">
{' '}| Excesso: +{group.excesso}
</span>
)}
</div>
</div>
<Badge variant="outline">
{group.apontamentos.length} apontamentos
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{group.apontamentos.map((apt, aptIndex) => (
<div
key={aptIndex}
className={`p-2 rounded text-xs border ${
aptIndex === 0
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}
>
<div className="font-medium">
{aptIndex === 0 ? '✅ Manter' : '🗑️ Remover'}
</div>
<div>Data: {new Date(apt.data_apontamento).toLocaleDateString('pt-BR')}</div>
<div>Qtd: {apt.quantidade_produzida}</div>
<div>Criado: {new Date(apt.created_at).toLocaleDateString('pt-BR')}</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
{/* Botão para executar limpeza */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button
onClick={executeCleaning}
disabled={isCleaning}
className="bg-red-600 hover:bg-red-700 flex items-center gap-2"
>
{isCleaning ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Executar Limpeza Definitiva
</Button>
</div>
</div>
)}
{!duplicatesData.duplicatesFound && (
<Card>
<CardContent className="text-center py-8">
<div className="text-green-600 mb-2">✅</div>
<h3 className="font-medium mb-2">Nenhuma Duplicata Encontrada</h3>
<p className="text-muted-foreground">
A OF {ofNumber} está livre de duplicatas de apontamentos.
</p>
</CardContent>
</Card>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
+277
View File
@@ -0,0 +1,277 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { Switch } from '@/components/ui/switch';
import { Trash2, Edit, Plus, Code, Eye, EyeOff } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { useJsonCodes, JsonCode } from '@/hooks/useJsonCodes';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
export function JsonCodesManager() {
const { jsonCodes, loading, saveJsonCode, deleteJsonCode, toggleActiveStatus } = useJsonCodes();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCode, setEditingCode] = useState<JsonCode | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
json_code: '',
is_active: true
});
const handleOpenDialog = (code?: JsonCode) => {
if (code) {
setEditingCode(code);
setFormData({
name: code.name,
description: code.description || '',
json_code: JSON.stringify(code.json_code, null, 2),
is_active: code.is_active
});
} else {
setEditingCode(null);
setFormData({
name: '',
description: '',
json_code: '{\n \n}',
is_active: true
});
}
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
const jsonData = JSON.parse(formData.json_code);
await saveJsonCode({
id: editingCode?.id,
name: formData.name,
description: formData.description,
json_code: jsonData,
is_active: formData.is_active
});
setIsDialogOpen(false);
setEditingCode(null);
} catch (error) {
console.error('JSON inválido:', error);
alert('JSON inválido. Por favor, verifique a sintaxe.');
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return (
<Card className="bg-slate-800/50 border-slate-700">
<CardContent className="p-6">
<div className="text-white">Carregando códigos JSON...</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader>
<CardTitle className="text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<Code className="h-5 w-5" />
Gerenciamento de Códigos JSON
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => handleOpenDialog()}
className="bg-green-600 hover:bg-green-700"
>
<Plus className="h-4 w-4 mr-2" />
Novo Código
</Button>
</DialogTrigger>
<DialogContent className="bg-slate-800 border-slate-700 max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
{editingCode ? 'Editar Código JSON' : 'Novo Código JSON'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-white">Nome *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="bg-slate-700 border-slate-600 text-white"
placeholder="Nome do código JSON"
/>
</div>
<div>
<Label htmlFor="description" className="text-white">Descrição</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="bg-slate-700 border-slate-600 text-white"
placeholder="Descrição opcional"
/>
</div>
<div>
<Label htmlFor="json_code" className="text-white">Código JSON *</Label>
<Textarea
id="json_code"
value={formData.json_code}
onChange={(e) => setFormData({ ...formData, json_code: e.target.value })}
className="bg-slate-700 border-slate-600 text-white font-mono min-h-[300px]"
placeholder='{"exemplo": "valor"}'
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="is_active" className="text-white">Ativo</Label>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="border-slate-600 text-slate-300"
>
Cancelar
</Button>
<Button
onClick={handleSave}
className="bg-blue-600 hover:bg-blue-700"
disabled={!formData.name || !formData.json_code}
>
{editingCode ? 'Atualizar' : 'Salvar'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardTitle>
</CardHeader>
<CardContent>
{jsonCodes.length === 0 ? (
<div className="text-slate-400 text-center py-8">
Nenhum código JSON cadastrado ainda.
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-slate-700">
<TableHead className="text-slate-300">Nome</TableHead>
<TableHead className="text-slate-300">Descrição</TableHead>
<TableHead className="text-slate-300">Status</TableHead>
<TableHead className="text-slate-300">Criado em</TableHead>
<TableHead className="text-slate-300">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jsonCodes.map((code) => (
<TableRow key={code.id} className="border-slate-700">
<TableCell className="text-white font-medium">
{code.name}
</TableCell>
<TableCell className="text-slate-300">
{code.description || '-'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant={code.is_active ? 'default' : 'secondary'}>
{code.is_active ? 'Ativo' : 'Inativo'}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => toggleActiveStatus(code.id, !code.is_active)}
className="text-slate-400 hover:text-white"
>
{code.is_active ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</TableCell>
<TableCell className="text-slate-300">
{formatDate(code.created_at)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog(code)}
className="text-blue-400 hover:text-blue-300"
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-slate-800 border-slate-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
Confirmar exclusão
</AlertDialogTitle>
<AlertDialogDescription className="text-slate-300">
Tem certeza que deseja excluir o código "{code.name}"?
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-slate-600 text-slate-300">
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteJsonCode(code.id)}
className="bg-red-600 hover:bg-red-700"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}