315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import React, { useState, useCallback, useEffect } from 'react';
|
|
import { analyzeCertificate } from './services/aiService';
|
|
import { exportAsPdf } from './services/pdfService';
|
|
import type { ReportData } from './types';
|
|
import type { AIProvider } from './types/providers';
|
|
import { Header } from './components/Header';
|
|
import { FileUpload } from './components/FileUpload';
|
|
import { Loader } from './components/Loader';
|
|
import { ReportDisplay } from './components/ReportDisplay';
|
|
import { PrintableReport } from './components/PrintableReport';
|
|
import { DownloadIcon, EyeIcon, CogIcon } from './components/Icons';
|
|
import { ApiKeySetup } from './components/ApiKeySetup';
|
|
import { AdminPanel } from './components/AdminPanel';
|
|
|
|
const App: React.FC = () => {
|
|
const [viewMode, setViewMode] = useState<'analysis' | 'admin'>('analysis');
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [apiKey, setApiKey] = useState<string>('');
|
|
const [endpoint, setEndpoint] = useState<string>('');
|
|
const [provider, setProvider] = useState<AIProvider>('gemini');
|
|
const [model, setModel] = useState<string>('gemini-2.5-flash');
|
|
const [hasKey, setHasKey] = useState<boolean>(false);
|
|
const [reportsData, setReportsData] = useState<ReportData[] | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showSetup, setShowSetup] = useState<boolean>(false);
|
|
const [isConfigLoaded, setIsConfigLoaded] = useState<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
// 1. Tentar ler do localStorage primeiro
|
|
const savedApiKey = localStorage.getItem('api-key');
|
|
const savedProvider = localStorage.getItem('ai-provider') as AIProvider;
|
|
const savedModel = localStorage.getItem('model-' + savedProvider);
|
|
const savedEndpoint = localStorage.getItem('ollama-endpoint');
|
|
|
|
let hasLocal = false;
|
|
if (savedApiKey || savedEndpoint) {
|
|
setApiKey(savedApiKey || '');
|
|
if (savedProvider) setProvider(savedProvider);
|
|
if (savedModel) setModel(savedModel);
|
|
if (savedEndpoint) setEndpoint(savedEndpoint);
|
|
setHasKey(true);
|
|
setShowSetup(false);
|
|
hasLocal = true;
|
|
} else {
|
|
setShowSetup(true);
|
|
}
|
|
|
|
// 2. Sincronizar com as configurações globais persistidas no servidor (mesmo em outros navegadores)
|
|
fetch('/api/config')
|
|
.then(res => res.json())
|
|
.then(config => {
|
|
if (config.provider) {
|
|
setProvider(config.provider);
|
|
if (config.apiKey) setApiKey(config.apiKey);
|
|
if (config.model) setModel(config.model);
|
|
if (config.endpoint) setEndpoint(config.endpoint);
|
|
setHasKey(true);
|
|
setShowSetup(false);
|
|
|
|
// Sincroniza o localStorage
|
|
localStorage.setItem('ai-provider', config.provider);
|
|
if (config.apiKey) {
|
|
localStorage.setItem('api-key', config.apiKey);
|
|
localStorage.setItem('last-api-key', config.apiKey);
|
|
}
|
|
if (config.model) {
|
|
localStorage.setItem('model-' + config.provider, config.model);
|
|
}
|
|
if (config.endpoint) {
|
|
localStorage.setItem('ollama-endpoint', config.endpoint);
|
|
}
|
|
} else if (!hasLocal) {
|
|
setShowSetup(true);
|
|
}
|
|
setIsConfigLoaded(true);
|
|
})
|
|
.catch(err => {
|
|
console.warn('Erro ao carregar configurações do servidor:', err);
|
|
if (!hasLocal) {
|
|
setShowSetup(true);
|
|
}
|
|
setIsConfigLoaded(true);
|
|
});
|
|
}, []);
|
|
|
|
const handleFileChange = useCallback((selectedFile: File | null) => {
|
|
setFile(selectedFile);
|
|
if (!selectedFile) {
|
|
setReportsData(null);
|
|
setError(null);
|
|
}
|
|
}, []);
|
|
|
|
const handleKeySave = useCallback((key: string, newProvider: AIProvider, newModel: string, newEndpoint?: string) => {
|
|
setApiKey(key);
|
|
setProvider(newProvider);
|
|
setModel(newModel);
|
|
if (newEndpoint) {
|
|
setEndpoint(newEndpoint);
|
|
localStorage.setItem('ollama-endpoint', newEndpoint);
|
|
}
|
|
if (key) {
|
|
localStorage.setItem('api-key', key);
|
|
localStorage.setItem('last-api-key', key);
|
|
}
|
|
localStorage.setItem('ai-provider', newProvider);
|
|
localStorage.setItem('model-' + newProvider, newModel);
|
|
setHasKey(true);
|
|
setShowSetup(false);
|
|
|
|
// Salvar no servidor para persistência multi-navegador
|
|
fetch('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
provider: newProvider,
|
|
apiKey: key,
|
|
model: newModel,
|
|
endpoint: newEndpoint || ''
|
|
})
|
|
}).catch(err => console.error('Erro ao persistir configurações no servidor:', err));
|
|
}, []);
|
|
|
|
const handleAnalyzeClick = async () => {
|
|
if (provider !== 'ollama' && !apiKey) {
|
|
setError("A chave de API não foi encontrada. Por favor, configure-a novamente.");
|
|
setHasKey(false);
|
|
return;
|
|
}
|
|
if (provider === 'ollama' && !endpoint) {
|
|
setError("O endereço do Ollama não foi configurado. Por favor, configure-o.");
|
|
setHasKey(false);
|
|
return;
|
|
}
|
|
if (!file) {
|
|
setError("Por favor, selecione um arquivo primeiro.");
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setReportsData(null);
|
|
try {
|
|
const data = await analyzeCertificate({
|
|
provider,
|
|
apiKey,
|
|
model,
|
|
file,
|
|
endpoint: provider === 'ollama' ? endpoint : undefined
|
|
});
|
|
setReportsData(data);
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
if (err.message.includes('API key') || err.message.includes('not valid') || err.message.includes('invalid')) {
|
|
setError('Erro: A chave de API fornecida não é válida. Por favor, insira uma nova chave.');
|
|
handleClearKey();
|
|
} else {
|
|
setError(`Erro na análise: ${err.message}`);
|
|
}
|
|
} else {
|
|
setError("Ocorreu um erro desconhecido durante a análise.");
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleReset = useCallback(() => {
|
|
setFile(null);
|
|
setReportsData(null);
|
|
setIsLoading(false);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const handleClearKey = useCallback(() => {
|
|
setApiKey('');
|
|
localStorage.removeItem('api-key');
|
|
setHasKey(false);
|
|
setShowSetup(true);
|
|
handleReset();
|
|
|
|
// Limpar no servidor
|
|
fetch('/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
}).catch(err => console.error('Erro ao limpar configurações no servidor:', err));
|
|
}, [handleReset]);
|
|
|
|
const handleExport = async (action: 'preview' | 'download') => {
|
|
if (action === 'download') {
|
|
setIsLoading(true);
|
|
try {
|
|
await exportAsPdf('printable-report-container', 'SteelCheck_Report', 'download');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
} else {
|
|
window.print();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="app-main-content">
|
|
{isLoading && <Loader />}
|
|
<Header
|
|
onReset={handleReset}
|
|
onClearKey={handleClearKey}
|
|
hasKey={hasKey}
|
|
provider={provider}
|
|
onToggleAdmin={() => setViewMode(viewMode === 'admin' ? 'analysis' : 'admin')}
|
|
isAdminView={viewMode === 'admin'}
|
|
/>
|
|
|
|
<main className="container mx-auto p-4 sm:p-6 md:p-8 max-w-7xl">
|
|
{!isConfigLoaded ? (
|
|
<div className="flex justify-center items-center py-20">
|
|
<span className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
|
|
</div>
|
|
) : viewMode === 'admin' ? (
|
|
<AdminPanel
|
|
provider={provider}
|
|
apiKey={apiKey}
|
|
model={model}
|
|
endpoint={endpoint}
|
|
/>
|
|
) : (!hasKey || showSetup) ? (
|
|
<ApiKeySetup onKeySave={handleKeySave} onCancel={hasKey ? () => setShowSetup(false) : undefined} />
|
|
) : !reportsData ? (
|
|
<div className="max-w-2xl mx-auto pt-8">
|
|
<div className="bg-white/70 dark:bg-slate-800/60 backdrop-blur-md border border-white/20 dark:border-slate-700/50 shadow-xl p-6 sm:p-10 rounded-2xl">
|
|
<div className="text-center mb-10">
|
|
<h2 className="text-3xl font-display font-bold text-slate-900 dark:text-white mb-2">
|
|
Análise Técnica Inteligente
|
|
</h2>
|
|
<p className="text-slate-500 dark:text-slate-400">
|
|
Carregue seu certificado de qualidade e deixe nossa IA verificar a conformidade.
|
|
</p>
|
|
<div className="mt-4 flex items-center justify-center gap-2">
|
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-slate-100 dark:bg-slate-700 rounded-full text-xs text-slate-600 dark:text-slate-300">
|
|
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
|
{provider.toUpperCase()} • {model}
|
|
</div>
|
|
<button
|
|
onClick={() => setShowSetup(true)}
|
|
className="p-1 rounded-full text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors"
|
|
title="Configurar API / Trocar Modelo"
|
|
>
|
|
<CogIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<FileUpload onFileChange={handleFileChange} />
|
|
|
|
{file && (
|
|
<button
|
|
onClick={handleAnalyzeClick}
|
|
disabled={isLoading || !file}
|
|
title={!file ? "Por favor, selecione um arquivo para analisar" : "Analisar Certificado"}
|
|
className="mt-8 w-full flex justify-center items-center gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold py-4 px-6 rounded-xl shadow-lg shadow-blue-500/20 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300 transform active:scale-[0.99]"
|
|
>
|
|
Analisar Documento
|
|
</button>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mt-6 text-center text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-4 rounded-xl border border-red-100 dark:border-red-900/30 text-sm">
|
|
<p>{error}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-center text-xs text-slate-400 dark:text-slate-600 mt-6 font-medium">
|
|
IA Assistiva • Verificação profissional recomendada
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="flex flex-col sm:flex-row gap-3 justify-end mb-8">
|
|
<button
|
|
onClick={() => handleExport('preview')}
|
|
className="flex items-center justify-center gap-2 w-full sm:w-auto bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 font-medium py-2.5 px-5 rounded-lg border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700 transition-all shadow-sm"
|
|
>
|
|
<EyeIcon className="w-5 h-5 text-slate-500" />
|
|
Visualizar PDF
|
|
</button>
|
|
<button
|
|
onClick={() => handleExport('download')}
|
|
className="flex items-center justify-center gap-2 w-full sm:w-auto bg-slate-900 dark:bg-white text-white dark:text-slate-900 font-semibold py-2.5 px-5 rounded-lg hover:opacity-90 transition-all shadow-lg shadow-slate-900/10"
|
|
>
|
|
<DownloadIcon className="w-5 h-5" />
|
|
Baixar Relatório
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col gap-8">
|
|
{reportsData.map((report, idx) => (
|
|
<ReportDisplay key={idx} report={report} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{reportsData && (
|
|
<div className="app-print-wrapper">
|
|
<PrintableReport reports={reportsData} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App; |