Deploy Inicial do SteelCheck com Docker Build Automatizado

This commit is contained in:
Marcos
2026-03-22 17:19:10 -03:00
commit 5e7dd082e3
24 changed files with 4324 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

176
App.tsx Normal file
View File

@@ -0,0 +1,176 @@
import React, { useState, useCallback, useEffect } from 'react';
import { analyzeCertificate } from './services/geminiService';
import { exportAsPdf } from './services/pdfService';
import type { ReportData } from './types';
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 } from './components/Icons';
import { ApiKeySetup } from './components/ApiKeySetup';
const App: React.FC = () => {
const [file, setFile] = useState<File | null>(null);
const [apiKey, setApiKey] = useState<string>('');
const [hasKey, setHasKey] = useState<boolean>(false);
const [reportData, setReportData] = useState<ReportData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const savedApiKey = localStorage.getItem('gemini-api-key');
if (savedApiKey) {
setApiKey(savedApiKey);
setHasKey(true);
}
}, []);
const handleFileChange = useCallback((selectedFile: File | null) => {
setFile(selectedFile);
// Reset state if file is removed
if (!selectedFile) {
setReportData(null);
setError(null);
}
}, []);
const handleKeySave = useCallback((key: string) => {
if (key) {
setApiKey(key);
localStorage.setItem('gemini-api-key', key);
setHasKey(true);
}
}, []);
const handleAnalyzeClick = async () => {
if (!apiKey) {
setError("A chave de API não foi encontrada. Por favor, configure-a novamente.");
setHasKey(false); // Force re-entry
return;
}
if (!file) {
setError("Por favor, selecione um arquivo primeiro.");
return;
}
setIsLoading(true);
setError(null);
setReportData(null);
try {
const data = await analyzeCertificate(file, apiKey);
setReportData(data);
} catch (err) {
if (err instanceof Error) {
if (err.message.includes('API key not valid')) {
setError('Erro: A chave de API fornecida não é válida. Por favor, insira uma nova chave.');
handleClearKey(); // Clear invalid key
} 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);
setReportData(null);
setIsLoading(false);
setError(null);
}, []);
const handleClearKey = useCallback(() => {
setApiKey('');
localStorage.removeItem('gemini-api-key');
setHasKey(false);
handleReset();
}, [handleReset]);
const handleExport = (action: 'preview' | 'download') => {
if (reportData) {
const fileName = `Relatorio_SteelBase_${reportData.identification.certificateNumber || 'analise'}`;
exportAsPdf('printable-report-container', fileName, action);
}
}
return (
<div className="min-h-screen">
{isLoading && <Loader />}
<Header onReset={handleReset} onClearKey={handleClearKey} hasKey={hasKey} />
<main className="container mx-auto p-4 sm:p-6 md:p-8 max-w-7xl">
{!hasKey ? (
<ApiKeySetup onKeySave={handleKeySave} />
) : !reportData ? (
<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>
<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>
<ReportDisplay report={reportData} />
</div>
)}
</main>
{/* Hidden component for PDF generation */}
{reportData && (
<div className="absolute -left-[9999px] top-0 opacity-0" aria-hidden="true">
<PrintableReport report={reportData} />
</div>
)}
</div>
);
};
export default App;

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
# Estágio de Build
FROM node:20-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install --frozen-lockfile || npm install
COPY . .
RUN npm run build
# Estágio de Produção
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1QJyTnh0ssUreUbbVXIVSPagOQEsRajVV
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { KeyIcon, SaveIcon } from './Icons';
interface ApiKeySetupProps {
onKeySave: (key: string) => void;
}
export const ApiKeySetup: React.FC<ApiKeySetupProps> = ({ onKeySave }) => {
const [localApiKey, setLocalApiKey] = useState('');
const handleSave = () => {
if (localApiKey.trim()) {
onKeySave(localApiKey.trim());
}
};
return (
<div className="max-w-md mx-auto animate-fade-in pt-10">
<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-8 rounded-2xl relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative z-10">
<h2 className="text-2xl font-display font-bold text-center mb-3 text-slate-800 dark:text-white">
Configurar Chave de API
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 text-center mb-8">
Para começar, insira sua chave de API do Google Gemini. Ela será mantida segura no seu navegador.
</p>
<div className="space-y-5">
<div>
<label htmlFor="api-key-setup" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Sua Chave de API
</label>
<div className="relative group/input">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<KeyIcon className="h-5 w-5 text-slate-400 group-focus-within/input:text-blue-500 transition-colors" />
</div>
<input
type="password"
id="api-key-setup"
className="block w-full rounded-xl border-slate-300 dark:border-slate-600 bg-white/50 dark:bg-slate-700/50 py-3 pl-10 pr-3 text-slate-900 dark:text-slate-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm backdrop-blur-sm transition-all"
placeholder="Cole sua chave aqui"
value={localApiKey}
onChange={(e) => setLocalApiKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
autoComplete="off"
/>
</div>
<p className="mt-3 text-xs text-center text-slate-500 dark:text-slate-400">
Não tem uma chave? <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:underline">Obtenha no Google AI Studio</a>.
</p>
</div>
<button
onClick={handleSave}
disabled={!localApiKey.trim()}
className="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-3.5 px-4 rounded-xl shadow-lg hover:shadow-blue-500/30 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.98]"
>
<SaveIcon className="h-5 w-5" />
<span>Salvar e Continuar</span>
</button>
</div>
</div>
</div>
</div>
);
};

114
components/FileUpload.tsx Normal file
View File

@@ -0,0 +1,114 @@
import React, { useState, useCallback, useRef } from 'react';
import { FileIcon, XIcon, UploadIcon } from './Icons';
interface FileUploadProps {
onFileChange: (file: File | null) => void;
}
export const FileUpload: React.FC<FileUploadProps> = ({ onFileChange }) => {
const [fileName, setFileName] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setFileName(file.name);
onFileChange(file);
}
}, [onFileChange]);
const handleRemoveFile = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setFileName(null);
onFileChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [onFileChange]);
const onDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const onDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) {
setFileName(file.name);
onFileChange(file);
}
}, [onFileChange]);
return (
<div className="w-full">
{!fileName ? (
<label
htmlFor="file-upload"
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`
group relative flex flex-col items-center justify-center w-full h-64
rounded-2xl border-2 border-dashed cursor-pointer transition-all duration-300
${isDragOver
? 'border-blue-500 bg-blue-50/50 dark:bg-blue-900/20 scale-[0.99]'
: 'border-slate-300 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-500 hover:bg-slate-50/50 dark:hover:bg-slate-800/50'
}
`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6 text-center">
<div className={`
p-4 rounded-full mb-4 transition-transform duration-300 group-hover:scale-110
${isDragOver ? 'bg-blue-100 text-blue-600' : 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400 group-hover:bg-blue-50 group-hover:text-blue-500 dark:group-hover:bg-slate-700'}
`}>
<UploadIcon className="w-8 h-8" />
</div>
<p className="mb-2 text-lg font-medium text-slate-700 dark:text-slate-200">
Clique ou arraste seu arquivo aqui
</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
PDF, JPG ou PNG (máx. 10MB)
</p>
</div>
<input
ref={fileInputRef}
id="file-upload"
name="file-upload"
type="file"
className="hidden"
onChange={handleFileSelect}
accept="image/*,application/pdf"
/>
</label>
) : (
<div className="group relative w-full p-4 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-sm hover:shadow-md transition-all flex items-center justify-between animate-fade-in">
<div className="flex items-center gap-4 overflow-hidden">
<div className="p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg text-blue-600 dark:text-blue-400">
<FileIcon className="w-6 h-6" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200 truncate pr-4">{fileName}</span>
<span className="text-xs text-green-600 dark:text-green-400 font-medium">Pronto para análise</span>
</div>
</div>
<button
onClick={handleRemoveFile}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-full transition-all"
title="Remover arquivo"
>
<XIcon className="w-5 h-5" />
</button>
</div>
)}
</div>
);
};

72
components/Header.tsx Normal file
View File

@@ -0,0 +1,72 @@
import React from 'react';
import { LogoBase64, SunIcon, MoonIcon, RefreshIcon, SignOutIcon } from './Icons';
import { useTheme } from '../context/ThemeContext';
interface HeaderProps {
onReset: () => void;
onClearKey: () => void;
hasKey: boolean;
}
export const Header: React.FC<HeaderProps> = ({ onReset, onClearKey, hasKey }) => {
const { theme, toggleTheme } = useTheme();
return (
<header className="sticky top-0 z-30 transition-all duration-300 backdrop-blur-md bg-white/70 dark:bg-slate-900/70 border-b border-white/20 dark:border-slate-800 shadow-sm">
<div className="container mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative group cursor-pointer">
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
<img src={LogoBase64} alt="SteelBase Logo" className="relative w-10 h-10 object-contain" />
</div>
<div className="hidden sm:block">
<h1 className="text-2xl font-display font-bold bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-700 dark:from-white dark:to-slate-300">
SteelBase
</h1>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-4">
{hasKey && (
<button
onClick={onClearKey}
className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium text-slate-500 hover:text-red-500 hover:bg-red-50 dark:text-slate-400 dark:hover:bg-red-900/20 transition-all border border-transparent hover:border-red-200 dark:hover:border-red-900"
title="Alterar Chave de API"
>
<SignOutIcon className="w-4 h-4" />
<span>Sair</span>
</button>
)}
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-full border border-slate-200 dark:border-slate-700">
<button
onClick={onReset}
className="p-2 rounded-full text-slate-500 dark:text-slate-400 hover:bg-white dark:hover:bg-slate-700 hover:shadow-sm focus:outline-none transition-all"
aria-label="Carregar outro arquivo"
title="Novo Relatório"
>
<RefreshIcon className="w-5 h-5" />
</button>
<div className="w-px h-4 bg-slate-300 dark:bg-slate-700 mx-1"></div>
<button
onClick={toggleTheme}
className="p-2 rounded-full text-slate-500 dark:text-slate-400 hover:bg-white dark:hover:bg-slate-700 hover:shadow-sm focus:outline-none transition-all relative overflow-hidden"
aria-label="Toggle theme"
>
<div className="relative z-10">
{theme === 'light' ? (
<MoonIcon className="w-5 h-5" />
) : (
<SunIcon className="w-5 h-5" />
)}
</div>
</button>
</div>
</div>
</div>
</header>
);
};

92
components/Icons.tsx Normal file
View File

@@ -0,0 +1,92 @@
import React from 'react';
// FIX: Truncated the corrupted base64 string and properly terminated it.
// The original string was malformed, containing parts of another component's code,
// which caused a syntax error and prevented any exports from this file.
export const LogoBase64 = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAACACAYAAAC6XbIEAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAvvSURBVHhe7Z1/aBxlHcd/7542cZPEJmliEyfxtEm825a0aVunbYy22bpp/6AtdGklbKFVqVT8QSoqCCIVqYACKlQEXQVBUBHFg4KogCiIAr+IKioqPlQEFV+kiD5UDC/8QUT0g4pYtGmbdvEmaRM38SZpEyfJzb7Pnd2d7M7szsiuuyf7/X7enXfZnZmdnZ25nZ2Z3ZlxQIIECRIk6F+S+v+vJEiQIEGCJEmQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEiQIEGCBAkSJEi`;
export const SunIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
);
export const MoonIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
);
export const RefreshIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0011.664 0l3.181-3.183m-11.664 0l4.5-4.5a3.75 3.75 0 00-5.303 0l-4.5 4.5m11.664 0l-3.182 3.182a8.25 8.25 0 01-11.664 0l-3.182-3.182m11.664 0l-4.5-4.5a3.75 3.75 0 015.303 0l4.5 4.5" />
</svg>
);
export const UploadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
);
export const EyeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.432 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export const DownloadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
);
export const FileIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.158 10.308l-3.182-3.182A4.5 4.5 0 004.5 12.5v2.25A3.375 3.375 0 007.875 18h8.25A3.375 3.375 0 0019.5 14.75v-2.25a2.25 2.25 0 00-2.25-2.25H15M12 12.75h.008v.008H12v-.008z" />
</svg>
);
export const XIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export const CheckCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
export const XCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
export const InfoIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
export const KeyIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
);
export const SaveIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 8.25H7.5a2.25 2.25 0 00-2.25 2.25v9a2.25 2.25 0 002.25 2.25h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25H15M9 12l3 3m0 0l3-3m-3 3V2.25" />
</svg>
);
export const SignOutIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
);

30
components/Loader.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React, { useState, useEffect } from 'react';
const loadingMessages = [
"Analisando o certificado...",
"Extraindo dados com OCR...",
"Consultando normas técnicas...",
"Verificando propriedades mecânicas...",
"Analisando composição química...",
"Compilando o relatório final...",
];
export const Loader: React.FC = () => {
const [messageIndex, setMessageIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setMessageIndex((prevIndex) => (prevIndex + 1) % loadingMessages.length);
}, 2500);
return () => clearInterval(interval);
}, []);
return (
<div className="fixed inset-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm flex flex-col justify-center items-center z-50">
<div className="w-16 h-16 border-4 border-dashed rounded-full animate-spin border-blue-500"></div>
<p className="mt-4 text-gray-800 dark:text-white text-lg font-semibold">{loadingMessages[messageIndex]}</p>
</div>
);
};

View File

@@ -0,0 +1,105 @@
import React from 'react';
import type { ReportData, ComplianceItem } from '../types';
interface PrintableReportProps {
report: ReportData;
}
const formatDate = (dateString: string) => {
// Tries to parse common date formats like DD.MM.YYYY or YYYY-MM-DD
const parts = dateString.split(/[.\-/]/);
if (parts.length === 3) {
// Assuming DD.MM.YYYY
if (parts[0].length === 2) return `${parts[0]}/${parts[1]}/${parts[2]}`;
// Assuming YYYY-MM-DD
if (parts[0].length === 4) return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
return dateString; // fallback
};
const TableRow: React.FC<{item: ComplianceItem}> = ({item}) => (
<tr className="border-b text-xs">
<td className="py-1 px-2 font-medium">{item.property || item.element || item.test}</td>
<td className="py-1 px-2">{item.norm}</td>
<td className="py-1 px-2 font-bold">{item.certificate}</td>
<td className={`py-1 px-2 font-bold ${item.status === 'OK' ? 'text-green-700' : 'text-red-700'}`}>{item.status}</td>
</tr>
)
export const PrintableReport: React.FC<PrintableReportProps> = ({ report }) => {
const { identification, compliance, overPerformance, equivalents, confidence } = report;
return (
<div id="printable-report-container" style={{ width: '210mm', height: '297mm' }} className="bg-white text-gray-900 p-8 font-sans flex flex-col">
<header className="flex justify-between items-start pb-2 border-b-2 border-gray-400">
<div>
<h1 className="text-3xl font-bold text-blue-800">SteelBase</h1>
<p className="text-sm">Análise de Qualidade Industrial com IA</p>
</div>
<div className="text-right">
<h2 className="text-2xl font-bold">Relatório de Análise Técnica</h2>
<p className="text-sm">Documento gerado em: {new Date().toLocaleDateString('pt-BR')}</p>
</div>
</header>
<main className="flex-grow">
<section className="mt-4 border border-gray-300 rounded p-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><strong>Produto:</strong> {identification.product}</div>
<div><strong>Norma Principal:</strong> {identification.standards}</div>
<div><strong>Fabricante:</strong> {identification.manufacturer}</div>
<div><strong> Certificado:</strong> {identification.certificateNumber}</div>
<div><strong>Lotes:</strong> {identification.batches}</div>
<div><strong>Corridas:</strong> {identification.heats}</div>
<div><strong>Quantidade:</strong> {identification.quantity}</div>
<div><strong>Data Emissão:</strong> {formatDate(identification.certificateDate)}</div>
</section>
<section className="mt-4">
<h3 className="text-base font-bold text-gray-800">Resumo da Análise</h3>
<div className="mt-1 border border-gray-300 rounded p-2 text-xs">
<p>O material de {identification.product} atende aos requisitos da norma {identification.standards}. Status Geral de Conformidade: <span className={`font-extrabold ${compliance.status === 'CONFORME' ? 'text-green-700' : 'text-red-700'}`}>{compliance.status}</span>.</p>
{compliance.status === 'CONFORME' && overPerformance.length > 0 && (
<p className="mt-1">O material excede os requisitos normativos em pontos-chave, como {overPerformance.map(item => item.property).join(', ')}.</p>
)}
</div>
</section>
<section className="mt-4">
<h3 className="text-base font-bold text-gray-800">Análise de Composição Química</h3>
<table className="w-full mt-1 border-collapse border border-gray-300">
<thead className="bg-gray-200 text-left text-xs uppercase">
<tr><th className="py-1 px-2">Elemento</th><th className="py-1 px-2">Norma</th><th className="py-1 px-2">Certificado</th><th className="py-1 px-2">Status</th></tr>
</thead>
<tbody>{compliance.chemical.map((item, i) => <TableRow key={i} item={item} />)}</tbody>
</table>
</section>
<section className="mt-4">
<h3 className="text-base font-bold text-gray-800">Análise de Propriedades Mecânicas</h3>
<table className="w-full mt-1 border-collapse border border-gray-300">
<thead className="bg-gray-200 text-left text-xs uppercase">
<tr><th className="py-1 px-2">Propriedade</th><th className="py-1 px-2">Norma</th><th className="py-1 px-2">Certificado</th><th className="py-1 px-2">Status</th></tr>
</thead>
<tbody>{compliance.mechanical.map((item, i) => <TableRow key={i} item={item} />)}</tbody>
</table>
</section>
<section className="mt-4">
<h3 className="text-base font-bold text-gray-800">Normas Equivalentes</h3>
<div className="mt-1 grid grid-cols-4 gap-2">
{equivalents.map((item, i) => (
<div key={i} className="bg-gray-100 border border-gray-300 rounded p-2 text-center">
<p className="text-xs font-semibold text-gray-600">{item.system}</p>
<p className="text-sm font-bold text-blue-800">{item.norm}</p>
</div>
))}
</div>
</section>
</main>
<footer className="text-center text-xs text-gray-500 pt-2 border-t border-gray-300">
Relatório gerado por SteelBase | Confiança da Análise: {confidence}%
</footer>
</div>
);
};

View File

@@ -0,0 +1,213 @@
import React from 'react';
import type { ReportData, ComplianceItem } from '../types';
import { CheckCircleIcon, XCircleIcon, InfoIcon } from './Icons';
interface ReportDisplayProps {
report: ReportData;
}
const statusClass = (status: 'OK' | 'FALHA' | 'CONFORME' | 'NÃO CONFORME') => {
switch (status) {
case 'OK':
case 'CONFORME':
return 'text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800';
case 'FALHA':
case 'NÃO CONFORME':
return 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800';
default:
return 'text-slate-600 bg-slate-50 dark:bg-slate-800/50 dark:text-slate-400 border border-slate-200 dark:border-slate-700';
}
};
const statusIcon = (status: 'OK' | 'FALHA') => {
return status === 'OK'
? <CheckCircleIcon className="w-4 h-4" />
: <XCircleIcon className="w-4 h-4" />;
};
const ComplianceTableRow: React.FC<{ item: ComplianceItem; headerLabel: string }> = ({ item, headerLabel }) => (
<tr className="block md:table-row mb-4 md:mb-0 bg-white/50 dark:bg-slate-800/50 md:bg-transparent rounded-xl md:rounded-none shadow-sm md:shadow-none border border-slate-100 dark:border-slate-700 md:border-b md:border-slate-200/50 md:dark:border-slate-700/50 last:border-0 hover:bg-white/80 dark:hover:bg-slate-800/80 transition-colors">
<td className="p-4 md:py-4 md:px-6 flex justify-between items-center border-b border-slate-100 dark:border-slate-700 md:border-0 md:table-cell">
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 md:hidden">{headerLabel}</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{item.property || item.element || item.test}</span>
</td>
<td className="p-4 md:py-4 md:px-6 flex justify-between items-center border-b border-slate-100 dark:border-slate-700 md:border-0 md:table-cell">
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 md:hidden">Norma</span>
<span className="text-slate-500 dark:text-slate-400 text-sm font-mono">{item.norm}</span>
</td>
<td className="p-4 md:py-4 md:px-6 flex justify-between items-center border-b border-slate-100 dark:border-slate-700 md:border-0 md:table-cell">
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 md:hidden">Certificado</span>
<span className="text-slate-800 dark:text-slate-200 font-semibold font-mono">{item.certificate}</span>
</td>
<td className="p-4 md:py-4 md:px-6 flex justify-between items-center md:table-cell">
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 md:hidden">Status</span>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold uppercase tracking-wide ${statusClass(item.status)}`}>
{statusIcon(item.status)}
{item.status}
</span>
</td>
</tr>
);
export const ReportDisplay: React.FC<ReportDisplayProps> = ({ report }) => {
const { identification, compliance, overPerformance, equivalents, confidence } = report;
return (
<div className="space-y-8 animate-fade-in pb-12">
<div className="text-center space-y-2">
<h2 className="text-3xl md:text-4xl font-display font-bold text-slate-900 dark:text-white">Relatório Técnico de Qualidade</h2>
<p className="text-slate-500 dark:text-slate-400">Análise de conformidade normativa assistida por IA</p>
</div>
<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-8 rounded-2xl text-center relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500"></div>
<h3 className="text-lg font-medium text-slate-500 dark:text-slate-400 mb-2 uppercase tracking-wide">Grau de Confiança da Análise</h3>
<div className="flex justify-center items-end gap-2">
<span className="text-6xl sm:text-7xl font-display font-bold bg-clip-text text-transparent bg-gradient-to-br from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400">{confidence}</span>
<span className="text-3xl font-bold text-slate-400 mb-2">%</span>
</div>
</div>
{/* Identification Section */}
<div className="bg-white/70 dark:bg-slate-800/60 backdrop-blur-md border border-white/20 dark:border-slate-700/50 shadow-xl rounded-2xl overflow-hidden">
<div className="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/50 border-b border-slate-200/50 dark:border-slate-700/50 backdrop-blur-sm">
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">1. Dados de Identificação</h3>
</div>
<div className="p-6">
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
{Object.entries(identification).map(([key, value]) => (
<li key={key} className="flex flex-col border-b border-slate-100 dark:border-slate-800/50 pb-3 last:border-0 last:pb-0">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">{key.replace(/([A-Z])/g, ' $1')}</span>
<span className="text-lg font-medium text-slate-800 dark:text-slate-200">{value}</span>
</li>
))}
</ul>
</div>
</div>
{/* Compliance Section */}
<div className="bg-white/70 dark:bg-slate-800/60 backdrop-blur-md border border-white/20 dark:border-slate-700/50 shadow-xl rounded-2xl overflow-hidden">
<div className="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/50 border-b border-slate-200/50 dark:border-slate-700/50 backdrop-blur-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">2. Verificação de Conformidade</h3>
<div className={`px-4 py-1.5 rounded-full font-bold text-sm uppercase tracking-wide border shadow-sm ${statusClass(compliance.status)}`}>
{compliance.status}
</div>
</div>
<div className="p-6 space-y-8">
<div>
<h4 className="flex items-center gap-2 font-display font-semibold text-lg mb-4 text-slate-800 dark:text-slate-200">
<span className="w-1.5 h-6 bg-blue-500 rounded-full"></span>
Análise de Propriedades Mecânicas
</h4>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="hidden md:table-header-group text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b-2 border-slate-100 dark:border-slate-700">
<tr>
<th className="py-3 px-6 font-semibold">Propriedade</th>
<th className="py-3 px-6 font-semibold">Norma</th>
<th className="py-3 px-6 font-semibold">Certificado</th>
<th className="py-3 px-6 font-semibold w-32">Status</th>
</tr>
</thead>
<tbody className="md:divide-y md:divide-slate-100 dark:md:divide-slate-700/50">
{compliance.mechanical.map((item, i) => <ComplianceTableRow key={i} item={item} headerLabel="Propriedade" />)}
</tbody>
</table>
</div>
</div>
<div>
<h4 className="flex items-center gap-2 font-display font-semibold text-lg mb-4 text-slate-800 dark:text-slate-200">
<span className="w-1.5 h-6 bg-purple-500 rounded-full"></span>
Análise de Composição Química (%)
</h4>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="hidden md:table-header-group text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b-2 border-slate-100 dark:border-slate-700">
<tr>
<th className="py-3 px-6 font-semibold">Elemento</th>
<th className="py-3 px-6 font-semibold">Norma</th>
<th className="py-3 px-6 font-semibold">Certificado</th>
<th className="py-3 px-6 font-semibold w-32">Status</th>
</tr>
</thead>
<tbody className="md:divide-y md:divide-slate-100 dark:md:divide-slate-700/50">
{compliance.chemical.map((item, i) => <ComplianceTableRow key={i} item={item} headerLabel="Elemento" />)}
</tbody>
</table>
</div>
</div>
{compliance.otherTests.length > 0 && (
<div>
<h4 className="flex items-center gap-2 font-display font-semibold text-lg mb-4 text-slate-800 dark:text-slate-200">
<span className="w-1.5 h-6 bg-emerald-500 rounded-full"></span>
Outros Testes
</h4>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="hidden md:table-header-group text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b-2 border-slate-100 dark:border-slate-700">
<tr>
<th className="py-3 px-6 font-semibold">Teste</th>
<th className="py-3 px-6 font-semibold">Norma</th>
<th className="py-3 px-6 font-semibold">Certificado</th>
<th className="py-3 px-6 font-semibold w-32">Status</th>
</tr>
</thead>
<tbody className="md:divide-y md:divide-slate-100 dark:md:divide-slate-700/50">
{compliance.otherTests.map((item, i) => <ComplianceTableRow key={i} item={item} headerLabel="Teste" />)}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
{/* Over-Performance Section */}
{compliance.status === 'CONFORME' && overPerformance.length > 0 && (
<div className="bg-white/70 dark:bg-slate-800/60 backdrop-blur-md border border-white/20 dark:border-slate-700/50 shadow-xl rounded-2xl overflow-hidden border-l-4 border-l-green-500">
<div className="px-6 py-4 bg-green-50/50 dark:bg-green-900/10 border-b border-green-100 dark:border-green-900/30">
<h3 className="text-xl font-display font-bold text-green-800 dark:text-green-300">3. Destaques de Desempenho</h3>
</div>
<div className="p-6">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6">Este material excede os requisitos mínimos normativos nos seguintes aspectos:</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{overPerformance.map((item, i) => (
<div key={i} className="flex items-start gap-4 p-4 rounded-xl bg-white/50 dark:bg-slate-800/50 border border-green-100 dark:border-green-900/30 shadow-sm">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-full text-green-600 dark:text-green-400 shrink-0">
<CheckCircleIcon className="w-5 h-5" />
</div>
<div>
<span className="block font-semibold text-slate-800 dark:text-slate-200">{item.property}</span>
<span className="text-green-600 dark:text-green-400 text-sm font-medium">{item.value} superior ao mínimo.</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Equivalents Section */}
<div className="glass-panel rounded-2xl overflow-hidden">
<div className="px-6 py-4 bg-slate-50/50 dark:bg-slate-800/50 border-b border-slate-200/50 dark:border-slate-700/50 backdrop-blur-sm">
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">4. Normas Equivalentes</h3>
</div>
<div className="p-6">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">Equivalências internacionais para <span className="font-semibold text-slate-700 dark:text-slate-300">{identification.standards}</span>:</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{equivalents.map((item, i) => (
<div key={i} className="group p-4 rounded-xl bg-gradient-to-br from-slate-50 to-white dark:from-slate-800 dark:to-slate-800/50 border border-slate-200 dark:border-slate-700 shadow-sm hover:shadow-md transition-all hover:-translate-y-1">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-1 group-hover:text-blue-500 transition-colors">{item.system}</p>
<p className="font-bold text-slate-800 dark:text-slate-200 font-mono text-lg">{item.norm}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
};

45
context/ThemeContext.tsx Normal file
View File

@@ -0,0 +1,45 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme as Theme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

31
index.css Normal file
View File

@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-100 dark:bg-slate-900 text-slate-900 dark:text-slate-100 transition-colors duration-300 font-sans;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.dark body {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
}
@layer components {
.glass-panel {
@apply bg-white/70 dark:bg-slate-800/60 backdrop-blur-md border border-white/20 dark:border-slate-700/50 shadow-xl;
}
.glass-card {
@apply bg-white/50 dark:bg-slate-800/40 backdrop-blur-sm border border-white/30 dark:border-slate-700/30 hover:bg-white/60 dark:hover:bg-slate-800/50 transition-all duration-300;
}
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}

62
index.html Normal file
View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SteelBase - Análise de Qualidade Industrial com IA</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;700;800&display=swap"
rel="stylesheet">
<script>
// Initialize theme from localStorage to prevent flash of unstyled content
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
display: ['Outfit', 'sans-serif'],
},
colors: {
glass: {
100: 'rgba(255, 255, 255, 0.1)',
200: 'rgba(255, 255, 255, 0.2)',
300: 'rgba(255, 255, 255, 0.3)',
dark: 'rgba(15, 23, 42, 0.6)',
}
}
}
}
}
</script>
<script type="importmap">
{
"imports": {
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.29.1",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"html2canvas": "https://aistudiocdn.com/html2canvas@^1.4.1",
"jspdf": "https://aistudiocdn.com/jspdf@^3.0.3"
}
}
</script>
</head>
<body
class="bg-slate-100 dark:bg-slate-900 text-slate-900 dark:text-slate-100 transition-colors duration-300 font-sans">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

19
index.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ThemeProvider } from './context/ThemeContext';
import './index.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "SteelBase",
"description": "A foundation for the SteelBase industrial analysis application.",
"requestFramePermissions": []
}

2838
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "steelbase",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"@google/genai": "^1.29.1",
"react-dom": "^19.2.0",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

43
push_steelcheck.py Normal file
View File

@@ -0,0 +1,43 @@
import subprocess
import os
import urllib.parse
# Configurações do Gitea
GITEA_USER = "admtracksteel"
GITEA_PASS = "@@Gi05Br;;"
GITEA_URL = "git.reifonas.cloud:22222"
GITEA_REPO = "SteelCheck"
# URL codificada para evitar problemas com caracteres especiais
encoded_pass = urllib.parse.quote(GITEA_PASS)
remote_url = f"https://{GITEA_USER}:{encoded_pass}@{GITEA_URL}/{GITEA_USER}/{GITEA_REPO}.git"
def run_command(command, description):
print(f"Executando: {description}...")
try:
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
print(f"Sucesso em {description}")
return result.stdout
except subprocess.CalledProcessError as e:
print(f"Erro em {description}: {e.stderr}")
return None
# Mudar para o diretório do projeto
os.chdir(r"m:\OFICIAIS E FUNCIONANDO\SteelCheck_base")
# Inicializar Git se necessário
if not os.path.exists(".git"):
run_command("git init", "inicializar git")
# Configurar Git localmente para evitar erros de autenticação
run_command(f'git remote remove origin', "limpar remote antigo")
run_command(f'git remote add origin {remote_url}', "adicionar novo remote")
# Add e Commit
run_command("git add .", "adicionar arquivos")
run_command('git commit -m "Deploy Inicial do SteelCheck com Docker Build Automatizado"', "fazer commit inicial")
# Push final
run_command("git push -u origin main --force", "fazer push para o Gitea")
print("\n--- Processo SteelCheck Concluído! ---")

174
services/geminiService.ts Normal file
View File

@@ -0,0 +1,174 @@
import { GoogleGenAI, Type } from "@google/genai";
import type { ReportData } from '../types';
const fileToGenerativePart = async (file: File) => {
const base64EncodedDataPromise = new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve((reader.result as string).split(',')[1]);
reader.readAsDataURL(file);
});
return {
inlineData: { data: await base64EncodedDataPromise, mimeType: file.type },
};
};
/**
* Cleans and parses a JSON string, removing potential markdown code blocks.
* @param jsonString The raw string from the AI response.
* @returns The parsed JSON object.
*/
const cleanAndParseJson = (jsonString: string): any => {
const cleanedString = jsonString
.replace(/^```json\s*/, '')
.replace(/```$/, '')
.trim();
try {
return JSON.parse(cleanedString);
} catch (error) {
console.error("Failed to parse cleaned JSON. Original string:", jsonString);
console.error("Cleaned string:", cleanedString);
// Re-throw the parsing error to be caught by the caller
throw new Error("A resposta da IA não estava no formato JSON esperado, mesmo após a limpeza.");
}
}
const reportSchema = {
type: Type.OBJECT,
properties: {
confidence: { type: Type.NUMBER, description: "Grau de Confiança da Análise em percentual (ex: 98)." },
identification: {
type: Type.OBJECT,
properties: {
product: { type: Type.STRING, description: "Nome do produto/material (ex: Chapa de Aço)." },
standards: { type: Type.STRING, description: "Norma(s) principal(is) citada(s) no certificado." },
manufacturer: { type: Type.STRING, description: "Nome do fabricante ou revendedor." },
certificateNumber: { type: Type.STRING, description: "Número do certificado." },
certificateDate: { type: Type.STRING, description: "Data do certificado." },
batches: { type: Type.STRING, description: "Lista de lotes (batches)." },
heats: { type: Type.STRING, description: "Lista de corridas (heats)." },
quantity: { type: Type.STRING, description: "Volume, peso ou medida do material." },
},
required: ["product", "standards", "manufacturer", "certificateNumber", "certificateDate", "batches", "heats", "quantity"]
},
compliance: {
type: Type.OBJECT,
properties: {
status: { type: Type.STRING, enum: ["CONFORME", "NÃO CONFORME"], description: "Status geral de conformidade." },
mechanical: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
property: { type: Type.STRING, description: "Propriedade mecânica (ex: Limite de Escoamento)." },
norm: { type: Type.STRING, description: "Valor exigido pela norma (ex: Mín: 350 MPa)." },
certificate: { type: Type.STRING, description: "Valor encontrado no certificado." },
status: { type: Type.STRING, enum: ["OK", "FALHA"], description: "Status da propriedade." },
},
required: ["property", "norm", "certificate", "status"]
}
},
chemical: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
element: { type: Type.STRING, description: "Elemento químico (ex: Carbono (C))." },
norm: { type: Type.STRING, description: "Valor exigido pela norma (ex: Máx: 0.25%)." },
certificate: { type: Type.STRING, description: "Valor encontrado no certificado." },
status: { type: Type.STRING, enum: ["OK", "FALHA"], description: "Status do elemento." },
},
required: ["element", "norm", "certificate", "status"]
}
},
otherTests: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
test: { type: Type.STRING, description: "Outro teste relevante (ex: Teste de Impacto Charpy)." },
norm: { type: Type.STRING, description: "Valor exigido pela norma." },
certificate: { type: Type.STRING, description: "Valor encontrado no certificado." },
status: { type: Type.STRING, enum: ["OK", "FALHA"], description: "Status do teste." },
},
required: ["test", "norm", "certificate", "status"]
}
}
},
required: ["status", "mechanical", "chemical", "otherTests"]
},
overPerformance: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
property: { type: Type.STRING, description: "Ponto-chave de superdesempenho." },
value: { type: Type.STRING, description: "Percentual acima do mínimo normativo (ex: 12.5%)." },
},
required: ["property", "value"]
},
description: "Lista de pontos onde o material excede os requisitos mínimos. Preencher apenas se o status for 'CONFORME'."
},
equivalents: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
system: { type: Type.STRING, description: "Sistema da norma (ex: EN (Europa))." },
norm: { type: Type.STRING, description: "Norma equivalente (ex: S355J2)." },
},
required: ["system", "norm"]
},
description: "Lista de normas internacionais equivalentes."
}
},
required: ["confidence", "identification", "compliance", "overPerformance", "equivalents"]
};
export const analyzeCertificate = async (file: File, apiKey: string): Promise<ReportData> => {
if (!apiKey) {
throw new Error("A chave de API do Gemini não foi fornecida.");
}
const ai = new GoogleGenAI({ apiKey: apiKey });
const imagePart = await fileToGenerativePart(file);
const prompt = `Você é o "SteelCheck", um especialista sênior em engenharia de materiais, metalurgia e controle de qualidade, com profundo conhecimento em aços estruturais (ASTM, EN, DIN), parafusos (ASTM, ISO), consumíveis de soldagem (AWS, ISO) e revestimentos industriais (ISO, NACE, SSPC).
Sua tarefa é analisar o Certificado de Qualidade fornecido (que pode ser uma imagem ou um PDF) e gerar um "Relatório de Análise Técnica" em formato JSON, seguindo rigorosamente o schema fornecido.
**Instrução Crítica:** Antes de qualquer outra coisa, verifique a orientação do documento. Se ele estiver rotacionado (de lado ou de cabeça para baixo), ajuste-o mentalmente para a posição de leitura correta antes de iniciar a extração de dados. A análise correta depende da orientação adequada.
Execute as seguintes etapas:
1. **Extração de Dados (OCR):** Extraia todos os dados primários do certificado, já considerando a orientação correta.
2. **Identificação de Normas:** Identifique a(s) norma(s) que o certificado alega atender.
3. **Análise de Conformidade:** Baseado no seu conhecimento profundo, compare os resultados dos testes (Propriedades Mecânicas, Composição Química, etc.) com os requisitos mínimos e máximos especificados pela(s) norma(s). Determine o status como "OK" ou "FALHA" para cada item e um "status" geral como "CONFORME" ou "NÃO CONFORME".
4. **Análise de Superdesempenho:** Se o status geral for "CONFORME", calcule o quanto (em porcentagem) ele excede os requisitos-chave (ex: "18% acima do mínimo"). Se for "NÃO CONFORME", deixe a lista de superdesempenho vazia.
5. **Análise de Equivalência:** Pesquise e liste materiais funcionalmente equivalentes de outras normas importantes (EN, DIN, JIS, NBR, etc.).
6. **Grau de Confiança:** Forneça um Grau de Confiança (em %) sobre a precisão da sua análise, baseado na qualidade do documento.
Retorne APENAS o objeto JSON, sem nenhum texto ou formatação adicional.`;
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: { parts: [imagePart, { text: prompt }] },
config: {
responseMimeType: "application/json",
responseSchema: reportSchema,
temperature: 0.1,
}
});
try {
const text = response.text;
const data = cleanAndParseJson(text);
return data as ReportData;
} catch (e) {
console.error("Failed to parse JSON response:", e);
// Lançar o erro original ou um erro mais específico
if (e instanceof Error) {
throw e;
}
throw new Error("A resposta da IA não estava no formato JSON esperado.");
}
};

59
services/pdfService.ts Normal file
View File

@@ -0,0 +1,59 @@
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
export const exportAsPdf = async (elementId: string, fileName: string, action: 'preview' | 'download'): Promise<void> => {
const input = document.getElementById(elementId);
if (!input) {
console.error(`Element with id ${elementId} not found.`);
return;
}
try {
const canvas = await html2canvas(input, {
scale: 2, // Higher scale improves quality
useCORS: true,
logging: false,
});
const imgData = canvas.toDataURL('image/png');
// A4 dimensions in mm: 210 x 297
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
// Calculate the aspect ratio
const ratio = canvasWidth / canvasHeight;
let imgWidth = pdfWidth;
let imgHeight = imgWidth / ratio;
// If the calculated height is greater than the page height, scale down
if (imgHeight > pdfHeight) {
imgHeight = pdfHeight;
imgWidth = imgHeight * ratio;
}
const x = (pdfWidth - imgWidth) / 2;
const y = 0;
pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight);
if (action === 'download') {
pdf.save(`${fileName}.pdf`);
} else {
const pdfBlob = pdf.output('blob');
const pdfUrl = URL.createObjectURL(pdfBlob);
window.open(pdfUrl, '_blank');
URL.revokeObjectURL(pdfUrl);
}
} catch (error) {
console.error("Error generating PDF:", error);
}
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

45
types.ts Normal file
View File

@@ -0,0 +1,45 @@
export interface IdentificationData {
product: string;
standards: string;
manufacturer: string;
certificateNumber: string;
certificateDate: string;
batches: string;
heats: string;
quantity: string;
}
export interface ComplianceItem {
property?: string;
element?: string;
test?: string;
norm: string;
certificate: string;
status: 'OK' | 'FALHA';
}
export interface ComplianceData {
status: 'CONFORME' | 'NÃO CONFORME';
mechanical: ComplianceItem[];
chemical: ComplianceItem[];
otherTests: ComplianceItem[];
}
export interface OverPerformanceItem {
property: string;
value: string;
}
export interface EquivalentItem {
system: string;
norm: string;
}
export interface ReportData {
confidence: number;
identification: IdentificationData;
compliance: ComplianceData;
overPerformance: OverPerformanceItem[];
equivalents: EquivalentItem[];
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});