🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user