Upload source code

This commit is contained in:
2026-03-12 19:36:34 +00:00
parent 783b6cb7e8
commit c7fb0c8561
158 changed files with 22553 additions and 0 deletions

21
index.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/steelpaint_iconw.png" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/png" href="/steelpaint_icon.png" media="(prefers-color-scheme: dark)" />
<!-- Fallback for browsers without media query support -->
<link rel="icon" type="image/png" href="/steelpaint_icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<link rel="apple-touch-icon" href="/pwa-192x192.png">
<title>SteelPaint</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

73
package.json Normal file
View File

@@ -0,0 +1,73 @@
{
"name": "gpi-app",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"tsx watch src/server/index.ts\"",
"build:client": "vite build",
"build:server": "tsc -p tsconfig.server.json",
"build": "npm run build:client && npm run build:server",
"lint": "eslint .",
"preview": "vite preview",
"start": "node dist/server/index.js"
},
"dependencies": {
"@clerk/clerk-react": "^5.59.6",
"@clerk/localizations": "^3.35.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/mongoose": "^5.11.96",
"@types/uuid": "^10.0.0",
"@vercel/speed-insights": "^1.3.1",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dequal": "^2.0.3",
"dotenv": "^17.2.3",
"enhanced-resolve": "^5.18.4",
"express": "^5.2.1",
"lucide-react": "^0.562.0",
"mongodb": "^7.0.0",
"mongoose": "^9.1.5",
"multer": "^2.0.2",
"pdf-parse": "^1.1.1",
"prop-types": "^15.8.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-is": "^19.2.3",
"react-router-dom": "^7.12.0",
"recharts": "^3.7.0",
"search-web": "^1.0.3",
"serverless-http": "^4.0.0",
"tailwind-merge": "^3.4.0",
"tesseract.js": "^7.0.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vercel/node": "^5.5.28",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"concurrently": "^9.1.2",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"nodemon": "^3.1.11",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vite-plugin-pwa": "^1.2.0"
}
}

42
src/client/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

122
src/client/App.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { SignedIn, SignedOut, useOrganization } from '@clerk/clerk-react';
import { AuthProvider } from './context/AuthContext';
import { useAuth } from './context/useAuth';
import { SystemSettingsProvider } from './context/SystemSettingsContext';
import { NotificationProvider } from './contexts/NotificationContext';
import { Layout } from './components/Layout';
import { ProtectedRoute } from './components/ProtectedRoute';
import { ToastProvider } from './components/Toast';
import { ProjectList } from './pages/ProjectList';
import { ProjectDetails } from './pages/ProjectDetails';
import { SchemesList } from './pages/SchemesList';
import { InspectionsList } from './pages/InspectionsList';
import { DataSheetLibrary } from './pages/DataSheetLibrary';
import { YieldStudyDashboard } from './pages/YieldStudyDashboard';
import { AdminDashboard } from './pages/AdminDashboard';
import { DeveloperDashboard } from './pages/DeveloperDashboard';
import { CalculatorDashboard } from './pages/CalculatorDashboard';
import { StockDashboard } from './pages/StockDashboard';
import { GuestDashboard } from './pages/GuestDashboard';
import { Login } from './pages/Login';
import { OrganizationSelector } from './pages/OrganizationSelector';
import InstrumentList from './pages/InstrumentList';
const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isDeveloper, isLoading } = useAuth();
if (isLoading) return null;
if (!isDeveloper()) return <Navigate to="/" replace />;
return <>{children}</>;
};
const AppContent: React.FC = () => {
const { organization } = useOrganization();
console.log('AppContent rendered');
console.log('Current organization:', organization);
// If user is signed in but has no organization, show org selector
if (!organization) {
console.log('No organization - showing OrganizationSelector');
return <OrganizationSelector />;
}
console.log('Organization exists - showing main app');
return (
<ToastProvider>
<AuthProvider>
<SystemSettingsProvider>
<NotificationProvider>
<Layout>
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/guest-dashboard" element={<GuestDashboard />} />
<Route path="/projects" element={<ProjectList />} />
<Route path="/project/:id" element={<ProjectDetails />} />
<Route path="/schemes" element={<SchemesList />} />
<Route path="/inspections" element={<InspectionsList />} />
<Route path="/library" element={
<ProtectedRoute allowedRoles={['user', 'admin']}>
<DataSheetLibrary />
</ProtectedRoute>
} />
<Route path="/instruments" element={
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
<InstrumentList />
</ProtectedRoute>
} />
<Route path="/yield-study" element={
<ProtectedRoute allowedRoles={['user', 'admin']}>
<YieldStudyDashboard />
</ProtectedRoute>
} />
<Route path="/calculators" element={<CalculatorDashboard />} />
<Route
path="/admin"
element={
<ProtectedRoute allowedRoles={['admin']}>
<AdminDashboard />
</ProtectedRoute>
}
/>
<Route
path="/stock"
element={
<ProtectedRoute allowedRoles={['user', 'admin', 'guest']}>
<StockDashboard />
</ProtectedRoute>
}
/>
<Route
path="/developer"
element={
<DeveloperRoute>
<DeveloperDashboard />
</DeveloperRoute>
}
/>
</Routes>
</Layout>
</NotificationProvider>
</SystemSettingsProvider>
</AuthProvider>
</ToastProvider>
);
};
function App() {
return (
<Router>
<SignedOut>
<Login />
</SignedOut>
<SignedIn>
<AppContent />
</SignedIn>
</Router>
);
}
export default App;

BIN
src/client/assets/grade.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { usePresence } from '../hooks/usePresence';
export const ActiveUsers: React.FC = () => {
const { activeUsers } = usePresence();
// Filter out current user from display if desired, or keep them to show connectivity.
// The backend `getActiveUsers` optionally excludes self, but let's handle display logic here.
// Backend logic: _id: { $ne: currentUserId }
// So activeUsers only contains OTHER users.
if (activeUsers.length === 0) return null;
return (
<div className="flex items-center gap-1">
<div className="flex -space-x-2 overflow-hidden py-1">
{activeUsers.map((u) => (
<div
key={u._id}
className="relative inline-block group cursor-help"
title={`${u.name} (Online)`}
>
<div className="w-8 h-8 rounded-full bg-primary/10 border-2 border-surface text-xs font-bold flex items-center justify-center text-primary uppercase shadow-sm">
{u.name.charAt(0)}
</div>
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-surface transform translate-y-1/4 translate-x-1/4"></span>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black text-white text-[10px] rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
{u.name}
</div>
</div>
))}
</div>
{activeUsers.length > 3 && (
<span className="text-[10px] text-text-muted font-bold ml-1">+{activeUsers.length}</span>
)}
</div>
);
};

View File

@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import { HelpCircle, X } from 'lucide-react';
import gradeImage from '../assets/grade.jpg';
interface AdhesionGrade {
value: string;
label: string;
areaRemoved: string;
status: 'approved' | 'warning' | 'rejected' | 'critical';
statusLabel: string;
description: string;
spriteY: number;
}
const adhesionGrades: AdhesionGrade[] = [
{
value: '5B',
label: '5B / 5Y',
areaRemoved: '0%',
status: 'approved',
statusLabel: 'Aprovado',
description: 'As bordas dos cortes estão completamente lisas; nenhum quadradinho da grade se soltou. A grade parece intacta, apenas riscos finos.',
spriteY: 14
},
{
value: '4B',
label: '4B / 4Y',
areaRemoved: '< 5%',
status: 'approved',
statusLabel: 'Geralmente Aprovado',
description: 'Pequenas lascas de tinta se soltaram nas interseções dos cortes. A área afetada é inferior a 5% da área total da grade.',
spriteY: 29
},
{
value: '3B',
label: '3B / 3Y',
areaRemoved: '5 - 15%',
status: 'warning',
statusLabel: 'Limite Aceitável',
description: 'Pequenas lascas se soltaram ao longo das bordas e nas interseções. As linhas de corte parecem irregulares e alguns cantinhos dos quadrados sumiram.',
spriteY: 44
},
{
value: '2B',
label: '2B / 2Y',
areaRemoved: '15 - 35%',
status: 'rejected',
statusLabel: 'Geralmente Reprovado',
description: 'A tinta descascou ao longo das bordas e em partes dos quadrados. É visível que a tinta está falhando; faixas inteiras ao lado dos cortes podem ter saído.',
spriteY: 59
},
{
value: '1B',
label: '1B / 1Y',
areaRemoved: '35 - 65%',
status: 'rejected',
statusLabel: 'Reprovado',
description: 'A tinta descascou em fitas largas ou quadrados inteiros se soltaram. A grade está muito danificada, com grandes buracos.',
spriteY: 74
},
{
value: '0B',
label: '0B / 0Y',
areaRemoved: '> 65%',
status: 'critical',
statusLabel: 'Reprovado Crítico',
description: 'A descamação e remoção é pior que o grau 1B (mais de 65% da área). A maior parte da tinta na área do teste foi arrancada pela fita.',
spriteY: 89
}
];
interface AdhesionGradeSelectProps {
name: string;
label?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}
export const AdhesionGradeSelect: React.FC<AdhesionGradeSelectProps> = ({ name, label, value, onChange }) => {
const [showGuide, setShowGuide] = useState(false);
const getStatusColor = (status: AdhesionGrade['status']) => {
switch (status) {
case 'approved': return 'text-green-400 bg-green-500/20 border-green-500/30';
case 'warning': return 'text-amber-400 bg-amber-500/20 border-amber-500/30';
case 'rejected': return 'text-red-400 bg-red-500/20 border-red-500/30';
case 'critical': return 'text-red-500 bg-red-600/30 border-red-600/40';
default: return 'text-gray-400 bg-gray-500/20 border-gray-500/30';
}
};
return (
<div className="relative flex flex-col gap-1 w-full">
{/* Label with help button */}
<div className="flex items-center gap-1.5 mb-1">
{label && <label className="text-[10px] font-bold text-primary dark:text-primary-light uppercase tracking-[0.15em] ml-1">{label}</label>}
<button
type="button"
onClick={() => setShowGuide(true)}
className="text-primary hover:text-primary/80 transition-colors"
title="Ver guia de classificação ASTM D3359"
>
<HelpCircle size={16} />
</button>
</div>
<select
name={name}
title={label || 'Teste de Aderência'}
value={value}
onChange={onChange}
className="flex h-12 w-full rounded-xl border px-4 py-2 text-sm transition-all font-medium shadow-inner cursor-pointer appearance-none outline-none bg-no-repeat bg-[length:1.5em_1.5em] bg-[right_0.75rem_center] bg-[url('data:image/svg+xml,%3Csvg_xmlns=%27http://www.w3.org/2000/svg%27_fill=%27none%27_viewBox=%270_0_20_20%27%3E%3Cpath_stroke=%27%23fb923c%27_stroke-linecap=%27round%27_stroke-linejoin=%27round%27_stroke-width=%271.5%27_d=%27m6_8_4_4_4-4%27/%3E%3C/svg%3E')] bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">-- Selecione --</option>
{adhesionGrades.map((grade) => (
<option key={grade.value} value={grade.value}>
{grade.label}
</option>
))}
</select>
{/* Guide Modal */}
{showGuide && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
onClick={() => setShowGuide(false)}
/>
{/* Modal */}
<div className="fixed inset-4 sm:inset-8 md:inset-12 lg:inset-20 z-50 flex items-center justify-center">
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-4xl max-h-full overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-surface-soft">
<div>
<h2 className="text-lg font-bold text-text-main">Guia de Classificação ASTM D3359</h2>
<p className="text-xs text-text-muted">Método B - Teste de Aderência de Corte em Grade</p>
</div>
<button
type="button"
onClick={() => setShowGuide(false)}
className="p-2 rounded-lg hover:bg-surface-hover text-text-muted hover:text-text-main transition-all"
title="Fechar guia"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{/* Grade Image */}
<div className="mb-6 rounded-xl overflow-hidden border border-border bg-white">
<img
src={gradeImage}
alt="Guia Visual ASTM D3359"
className="w-full object-contain max-h-64 sm:max-h-80"
/>
</div>
{/* Grade Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{adhesionGrades.map((grade) => (
<div
key={grade.value}
className={`p-3 rounded-xl border ${getStatusColor(grade.status)}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-base font-bold">{grade.value}</span>
<span className="text-[10px] font-bold uppercase opacity-80">
{grade.areaRemoved}
</span>
</div>
<p className="text-xs font-semibold mb-1">{grade.statusLabel}</p>
<p className="text-[10px] opacity-80 leading-relaxed">
{grade.description}
</p>
</div>
))}
</div>
{/* Info Note */}
<div className="mt-4 p-3 rounded-xl bg-primary/10 border border-primary/20">
<p className="text-xs text-text-secondary">
<strong className="text-primary">Nota:</strong> A escala ASTM funciona como uma "nota escolar":
<strong className="text-green-400"> 5B é a nota máxima (perfeito)</strong> e
<strong className="text-red-400"> 0B é a nota mínima (reprovado)</strong>.
</p>
</div>
</div>
</div>
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react';
import { X, Archive, Trash2, RefreshCcw } from 'lucide-react';
import api from '../services/api';
import type { INotification } from '../types';
interface ArchivedNotificationsModalProps {
isOpen: boolean;
onClose: () => void;
}
export const ArchivedNotificationsModal: React.FC<ArchivedNotificationsModalProps> = ({
isOpen,
onClose,
}) => {
const [notifications, setNotifications] = useState<INotification[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchArchived = async () => {
setIsLoading(true);
try {
const response = await api.get<INotification[]>('/notifications?includeArchived=true');
// Filtrar apenas as arquivadas (no frontend por segurança, embora o backend já devesse ajudar)
// Na verdade, passamos includeArchived=true, o backend retornará unread + archived.
// Vamos filtrar para mostrar apenas o "Log" (arquivadas).
setNotifications(response.data.filter(n => n.isArchived || n.archivedBy?.length > 0));
} catch (error) {
console.error('Error fetching archived notifications:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isOpen) {
fetchArchived();
}
}, [isOpen]);
const deleteForever = async (id: string) => {
if (!window.confirm('Excluir permanentemente este registro do log?')) return;
try {
await api.delete(`/notifications/${id}`);
setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) {
console.error('Error deleting archived notification:', error);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-zinc-900 rounded-2xl shadow-2xl max-w-2xl w-full border border-zinc-800 animate-in slide-in-from-bottom-4 max-h-[80vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-zinc-800 bg-zinc-900">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-800 flex items-center justify-center">
<Archive className="text-zinc-400" size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-white">Log de Mensagens (Arquivadas)</h2>
<p className="text-sm text-zinc-500">
Histórico de notificações sistema
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-zinc-800 rounded-lg transition-colors"
>
<X size={20} className="text-zinc-500" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-zinc-950/50">
{isLoading ? (
<div className="text-center py-8">
<RefreshCcw className="animate-spin text-primary mx-auto mb-2" size={24} />
<p className="text-zinc-500">Carregando histórico...</p>
</div>
) : notifications.length === 0 ? (
<div className="text-center py-12">
<Archive size={48} className="text-zinc-800 mx-auto mb-4" />
<p className="text-zinc-500 font-semibold">Nenhuma mensagem arquivada</p>
<p className="text-zinc-600 text-sm mt-1">
Mensagens arquivadas aparecerão aqui para consulta.
</p>
</div>
) : (
<div className="space-y-3">
{notifications.map((msg) => (
<div
key={msg._id}
className="bg-zinc-900 border border-zinc-800 rounded-xl p-4 hover:border-zinc-700 transition-all group"
>
<div className="flex items-start justify-between mb-2">
<div className="flex flex-col">
<span className="font-bold text-zinc-200 text-sm">{msg.title}</span>
<span className="text-[10px] text-zinc-500">
{new Date(msg.createdAt).toLocaleString('pt-BR')}
</span>
</div>
<button
onClick={() => deleteForever(msg._id)}
className="opacity-0 group-hover:opacity-100 p-1.5 text-zinc-600 hover:text-red-500 transition-all"
title="Excluir permanentemente"
>
<Trash2 size={14} />
</button>
</div>
<p className="text-zinc-400 text-xs leading-relaxed">{msg.message}</p>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
<button
onClick={onClose}
className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg font-bold text-sm transition-colors"
>
Fechar
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { twMerge } from 'tailwind-merge';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
export const Button: React.FC<ButtonProps> = ({
className,
variant = 'primary',
size = 'md',
children,
...props
}) => {
const baseStyles = 'inline-flex items-center justify-center rounded-xl font-bold transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 tracking-wide';
const variants = {
primary: 'bg-primary text-background-dark shadow-lg shadow-primary/20 hover:shadow-glow-primary hover:bg-primary/90 focus:ring-primary font-black',
secondary: 'bg-surface-highlight/50 backdrop-blur-sm text-text-main border border-white/10 hover:bg-surface-hover hover:border-text-muted/30 focus:ring-accent',
success: 'bg-accent-green text-background-dark shadow-lg shadow-accent-green/20 hover:shadow-[0_0_20px_rgba(11,218,77,0.4)] hover:bg-accent-green/90 focus:ring-accent-green font-black',
danger: 'bg-error text-white shadow-lg shadow-error/20 hover:shadow-glow-error hover:bg-error/90 focus:ring-error font-bold',
ghost: 'bg-transparent hover:bg-white/5 text-text-muted hover:text-white'
};
const sizes = {
sm: 'h-9 px-4 text-xs',
md: 'h-11 px-6 py-2.5 text-sm',
lg: 'h-14 px-8 text-base shadow-xl'
};
return (
<button
className={twMerge(baseStyles, variants[variant], sizes[size], className)}
{...props}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { twMerge } from 'tailwind-merge';
interface CardProps {
children: React.ReactNode;
className?: string;
title?: string;
description?: string;
actions?: React.ReactNode;
onClick?: () => void;
}
export const Card: React.FC<CardProps> = ({ children, className, title, description, actions, onClick }) => {
return (
<div
onClick={onClick}
className={twMerge(
'glass-card rounded-[24px] p-6 border border-white/5',
'hover:border-primary/20 hover:shadow-lg transition-all duration-500',
onClick ? 'cursor-pointer active:scale-[0.98]' : '',
className
)}
>
{(title || actions) && (
<div className="flex justify-between items-start mb-6">
<div>
{title && <h3 className="text-xl font-bold text-text-main tracking-tight">{title}</h3>}
{description && <p className="text-sm text-text-muted font-medium mt-1">{description}</p>}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
)}
<div className="text-text-main">{children}</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import React, { useEffect, useRef } from 'react';
import { clsx } from 'clsx';
interface ColorBubbleProps {
colorHex?: string;
className?: string;
title?: string;
}
export const ColorBubble: React.FC<ColorBubbleProps> = ({ colorHex, className, title }) => {
const bubbleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (bubbleRef.current) {
if (colorHex) {
bubbleRef.current.style.setProperty('--bg-color', colorHex);
} else {
bubbleRef.current.style.removeProperty('--bg-color');
}
}
}, [colorHex]);
return (
<div
ref={bubbleRef}
className={clsx(
"w-4 h-4 rounded-full border shadow-inner transition-all duration-300 flex-shrink-0",
colorHex
? "border-border/40 dynamic-bg-color"
: "border-border/20 bg-transparent opacity-20 border-dashed",
className
)}
title={title || (colorHex ? `Cor: ${colorHex}` : 'Cor não definida')}
/>
);
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
import { AlertTriangle, HelpCircle } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
type?: 'danger' | 'warning' | 'info';
}
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
description,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
type = 'info'
}) => {
const getIcon = () => {
switch (type) {
case 'danger': return <AlertTriangle className="w-6 h-6 text-error" />;
case 'warning': return <AlertTriangle className="w-6 h-6 text-orange-500" />;
default: return <HelpCircle className="w-6 h-6 text-primary" />;
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title}>
<div className="space-y-6 pt-2">
<div className="flex gap-4 p-4 rounded-2xl bg-surface-soft border border-border/40">
<div className="flex-shrink-0">
{getIcon()}
</div>
<div>
<p className="text-sm font-medium text-text-secondary leading-relaxed">
{description}
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
variant="secondary"
onClick={onClose}
>
{cancelText}
</Button>
<Button
variant={type === 'danger' ? 'danger' : 'primary'}
onClick={onConfirm}
>
{confirmText}
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { twMerge } from 'tailwind-merge';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, label, error, ...props }, ref) => {
return (
<div className="flex flex-col gap-1 w-full">
{label && <label className="text-[10px] font-bold text-primary dark:text-primary-light uppercase tracking-[0.15em] ml-1 mb-1">{label}</label>}
<input
ref={ref}
className={twMerge(
'flex h-12 w-full rounded-xl border px-4 py-2 text-sm transition-all font-medium shadow-inner outline-none',
'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] placeholder:text-[var(--input-placeholder)]',
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-error focus:ring-error/10 focus:border-error',
className
)}
{...props}
/>
{error && <span className="text-sm text-error">{error}</span>}
</div>
);
});

View File

@@ -0,0 +1,418 @@
import React, { useState } from 'react';
import NotificationBell from './NotificationBell';
import { TeamPresence } from './TeamPresence';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer } from 'lucide-react';
import { clsx } from 'clsx';
import { useClerk, UserButton, useUser, OrganizationSwitcher, useOrganization } from '@clerk/clerk-react';
import { TechnicalManual } from './TechnicalManual';
import { useAuth } from '../context/useAuth';
// import { useSystemSettings } from '../context/SystemSettingsContext';
import { setApiOrgData } from '../services/api';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isManualOpen, setIsManualOpen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(() => {
const saved = localStorage.getItem('theme');
return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
});
const location = useLocation();
const { signOut } = useClerk();
const { user } = useUser();
const { organization } = useOrganization();
const { isAdmin, isUser, isDeveloper, appUser } = useAuth();
// const { settings } = useSystemSettings();
// Sync Organization ID with API client
React.useEffect(() => {
if (organization?.id) {
setApiOrgData(organization.id, organization.name);
} else {
setApiOrgData(null);
}
}, [organization]);
// Helper to get role display name
const getRoleDisplay = () => {
switch (appUser?.role) {
case 'admin': return { label: 'Admin', color: 'text-amber-500' };
case 'user': return { label: 'Usuário', color: 'text-green-500' };
case 'guest': return { label: 'Convidado', color: 'text-blue-400' };
default: return { label: 'Carregando...', color: 'text-text-muted' };
}
};
const roleInfo = getRoleDisplay();
React.useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}, [isDarkMode]);
const toggleTheme = () => setIsDarkMode(!isDarkMode);
const navigate = useNavigate();
// Guest Redirection Logic
React.useEffect(() => {
if (appUser?.role === 'guest' && (location.pathname === '/' || location.pathname === '/projects')) {
navigate('/guest-dashboard');
}
}, [appUser, location.pathname, navigate]);
interface NavItem {
icon: React.ElementType;
label: string;
path: string;
adminOnly?: boolean;
}
const navItems: NavItem[] = [
{ icon: LayoutDashboard, label: 'Painel Principal', path: '/guest-dashboard' },
{ icon: FolderOpen, label: 'Obras & Projetos', path: '/' },
{ icon: Layers, label: 'Esquemas', path: '/schemes' },
{ icon: ClipboardCheck, label: 'Inspeções', path: '/inspections' },
{ icon: FolderOpen, label: 'Biblioteca', path: '/library', adminOnly: true },
{ icon: Thermometer, label: 'Instrumentos', path: '/instruments' },
{ icon: TrendingUp, label: 'Estudo Rend.', path: '/yield-study', adminOnly: true },
{ icon: Package, label: 'Estoque', path: '/stock' },
{ icon: Wrench, label: 'Calculadora', path: '/calculators' },
];
const isActive = (path: string) => {
if (path === '/' && location.pathname === '/') return true;
if (path !== '/' && location.pathname.startsWith(path)) return true;
return false;
};
return (
<div className="min-h-screen bg-surface-soft flex font-sans selection:bg-primary/30">
{/* Sidebar Desktop - Fixed */}
<aside className="hidden md:flex flex-col w-68 border-r border-border/40 bg-surface fixed h-full z-20 shadow-xl shadow-black/20">
{/* Logo Area */}
<div className="flex items-center gap-3 px-2 mb-8">
<div className="w-10 h-10 flex items-center justify-center shrink-0">
<img
src={isDarkMode ? "/steelpaint_icon.png" : "/steelpaint_iconw.png"}
alt="SteelPaint Logo"
className="w-full h-full object-contain"
/>
</div>
<div>
<h1 className="font-black text-xl tracking-tight text-text-main leading-none">
SteelPaint
</h1>
<p className="text-[10px] font-bold text-primary uppercase tracking-wider mt-1">
GESTÃO DE PINTURA INDUSTRIAL
</p>
</div>
</div>
<div className="px-6 mb-2">
{isAdmin() ? (
<OrganizationSwitcher
hidePersonal={true}
afterSelectOrganizationUrl="/"
afterCreateOrganizationUrl="/"
afterLeaveOrganizationUrl="/"
appearance={{
elements: {
rootBox: "w-full",
organizationSwitcherTrigger: "w-full justify-between bg-surface-hover/50 hover:bg-surface-hover p-2 rounded-xl border border-border/50 text-text-main transition-all",
organizationPreviewTextContainer: "text-text-main",
organizationPreviewMainIdentifier: "text-text-main font-semibold",
organizationSwitcherPopoverCard: "bg-surface border border-border/40 shadow-2xl",
organizationSwitcherPopoverActions: "bg-surface-soft/50",
organizationSwitcherPopoverActionButton: "text-text-main hover:bg-surface-hover transition-colors",
organizationPreview: "hover:bg-surface-hover cursor-pointer transition-colors px-4 py-3",
organizationPreviewSecondaryIdentifier: "text-text-muted",
organizationSwitcherPopoverFooter: "hidden",
userPreviewMainIdentifier: "text-text-main font-bold",
userPreviewSecondaryIdentifier: "text-text-muted",
}
}}
/>
) : (
<div className="w-full flex items-center gap-3 p-2 rounded-xl border border-border/50 bg-surface-hover/50 text-text-main opacity-80 cursor-default" title="Apenas visualização">
{organization?.imageUrl ? (
<img src={organization.imageUrl} alt={organization.name} className="w-8 h-8 rounded-lg object-cover bg-surface-soft" />
) : (
<div className="w-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs">
{organization?.name?.substring(0, 2).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{organization?.name || 'Carregando...'}</p>
<p className="text-[10px] text-text-muted uppercase tracking-wider">Organização</p>
</div>
</div>
)}
</div>
{/* Team Presence - Shows all members with online/offline status */}
<TeamPresence />
<nav className="flex-1 overflow-y-auto py-6 px-4 space-y-1.5">
<div className="px-4 mb-2">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">Menu Principal</span>
</div>
{navItems.map((item) => {
if (item.adminOnly && !isAdmin() && !isUser()) return null;
return (
<Link
key={item.path}
to={item.path}
className={clsx(
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all group",
isActive(item.path)
? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-text-secondary hover:bg-surface-hover hover:text-text-main"
)}
>
<item.icon size={18} className={clsx(
"transition-colors",
isActive(item.path) ? "text-white" : "text-text-muted group-hover:text-primary"
)} />
{item.label}
</Link>
)
})}
{/* Admin Menu Item - Only visible for admins */}
{isAdmin() && (
<>
<div className="px-4 mt-6 mb-2">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">Sistema</span>
</div>
<Link
to="/admin"
className={clsx(
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all group",
isActive('/admin')
? "bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/20"
: "text-text-secondary hover:bg-surface-hover hover:text-text-main"
)}
>
<Shield size={18} className={clsx(
"transition-colors",
isActive('/admin') ? "text-white" : "text-amber-500 group-hover:text-amber-400"
)} />
Administração
</Link>
</>
)}
{/* Developer Menu Item - ONLY for admtracksteel@gmail.com */}
{(isDeveloper() || isAdmin() || isUser()) && (
<>
{!isAdmin() && (
<div className="px-4 mt-6 mb-2">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">Desenvolvedor</span>
</div>
)}
<Link
to="/developer"
className={clsx(
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all group",
isActive('/developer')
? "bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg shadow-indigo-500/20"
: "text-text-secondary hover:bg-surface-hover hover:text-text-main"
)}
>
<Terminal size={18} className={clsx(
"transition-colors",
isActive('/developer') ? "text-white" : "text-indigo-500 group-hover:text-indigo-400"
)} />
Área Dev
</Link>
</>
)}
</nav>
<div className="p-6 border-t border-border/40 space-y-4">
<button
onClick={toggleTheme}
className="flex items-center gap-3 w-full px-4 py-3 rounded-xl text-sm font-semibold text-text-secondary hover:bg-surface-hover transition-all"
>
{isDarkMode ? (
<>
<Sun size={18} className="text-yellow-500" />
<span>Modo Claro</span>
</>
) : (
<>
<Moon size={18} className="text-primary" />
<span>Modo Escuro</span>
</>
)}
</button>
<button
onClick={() => signOut()}
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-text-secondary hover:text-error hover:bg-error/5 transition-all w-full"
>
<LogOut size={18} />
Sair
</button>
<div className="pt-2 flex items-center justify-between px-2">
<div className="flex items-center gap-2">
<NotificationBell />
<div className="w-px h-6 bg-border/50 mx-1"></div>
<UserButton afterSignOutUrl="/" />
<div className="flex flex-col">
<span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{user?.firstName || 'Usuário'}</span>
<span className="text-[8px] text-text-muted">v2.1.0</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${appUser?.role === 'admin' ? 'bg-amber-500' : appUser?.role === 'user' ? 'bg-green-500' : 'bg-blue-400'}`}></span>
<span className={`text-[10px] font-medium ${roleInfo.color}`}>{roleInfo.label}</span>
</div>
</div>
</div>
</aside>
{/* Mobile Header */}
<header className="md:hidden fixed top-0 left-0 right-0 h-16 bg-surface/80 backdrop-blur-xl border-b border-border/40 z-30 flex items-center justify-between px-6">
<div className="flex items-center gap-3">
<img
src={isDarkMode ? "/steelpaint_icon.png" : "/steelpaint_iconw.png"}
alt="Logo"
className="w-8 h-8 object-contain"
/>
<span className="font-bold text-lg text-text-main">SteelPaint</span>
</div>
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 text-text-main hover:bg-surface-hover rounded-lg transition-colors"
>
{isSidebarOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</header>
{/* Mobile Sidebar Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/60 z-40 md:hidden backdrop-blur-sm transition-all animate-in fade-in"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Mobile Sidebar Drawer */}
<div className={clsx(
"fixed inset-y-0 left-0 w-72 bg-surface z-50 transform transition-transform duration-300 ease-out md:hidden flex flex-col border-r border-border/40 shadow-2xl",
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
)}>
<div className="p-8 border-b border-border/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<img
src={isDarkMode ? "/steelpaint_icon.png" : "/steelpaint_iconw.png"}
alt="Logo"
className="w-8 h-8 object-contain"
/>
<span className="font-bold text-lg text-text-main">SteelPaint</span>
</div>
<button onClick={() => setIsSidebarOpen(false)} className="p-2 text-text-muted hover:text-text-main" aria-label="Close Menu">
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto py-8 px-6 space-y-2">
{navItems.map((item) => {
if (item.adminOnly && !isAdmin()) return null;
return (
<Link
key={item.path}
to={item.path}
onClick={() => setIsSidebarOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-2xl text-base font-bold transition-all",
isActive(item.path)
? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-text-secondary hover:bg-surface-hover"
)}
>
<item.icon size={22} />
{item.label}
</Link>
)
})}
{/* Admin Menu Item - Mobile */}
{isAdmin() && (
<Link
to="/admin"
onClick={() => setIsSidebarOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-2xl text-base font-bold transition-all mt-4",
isActive('/admin')
? "bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/20"
: "text-text-secondary hover:bg-surface-hover"
)}
>
<Shield size={22} className="text-amber-500" />
Administração
</Link>
)}
{/* Developer Menu Item - Mobile */}
{(isDeveloper() || isAdmin() || isUser()) && (
<Link
to="/developer"
onClick={() => setIsSidebarOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-2xl text-base font-bold transition-all mt-4 font-mono",
isActive('/developer')
? "bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg shadow-indigo-500/20"
: "text-text-secondary hover:bg-surface-hover hover:text-indigo-400"
)}
>
<Terminal size={22} className="text-indigo-500" />
Área Dev
</Link>
)}
</nav>
<div className="p-8 border-t border-border/40 space-y-4">
<button onClick={toggleTheme} className="flex items-center gap-4 w-full text-text-main font-bold">
{isDarkMode ? <Sun size={20} className="text-yellow-500" /> : <Moon size={20} className="text-primary" />}
{isDarkMode ? 'Modo Claro' : 'Modo Escuro'}
</button>
</div>
</div>
{/* Main Content Area */}
<main className={clsx(
"flex-1 min-h-screen transition-all duration-300",
"md:pl-68", // Push content on desktop
"pt-16 md:pt-0" // Add padding on mobile for header
)}>
<div className="max-w-7xl mx-auto px-6 sm:px-10 lg:px-12 py-10 w-full animate-in fade-in slide-in-from-bottom-4 duration-500">
{children}
</div>
</main>
{/* Floating Help Button */}
<button
onClick={() => setIsManualOpen(true)}
className="fixed bottom-4 right-4 md:top-6 md:right-6 z-30 w-12 h-12 bg-primary hover:bg-primary-dark text-white rounded-xl shadow-lg shadow-primary/30 flex items-center justify-center transition-all hover:scale-105 group"
title="Manual Técnico"
aria-label="Abrir Manual Técnico"
>
<HelpCircle size={22} className="group-hover:scale-110 transition-transform" />
</button>
{/* Technical Manual Modal */}
<TechnicalManual isOpen={isManualOpen} onClose={() => setIsManualOpen(false)} />
</div>
);
};

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { Card } from './Card';
interface Column<T> {
header: string;
accessor: keyof T | ((item: T) => React.ReactNode);
className?: string; // For adding widths or hiding on smaller screens
}
interface MobileListProps<T> {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
onItemClick?: (item: T) => void;
titleAccessor?: keyof T | ((item: T) => React.ReactNode);
subtitleAccessor?: keyof T | ((item: T) => React.ReactNode);
actionRender?: (item: T) => React.ReactNode;
}
export const MobileList = <T,>({
data,
columns,
keyExtractor,
onItemClick,
titleAccessor,
subtitleAccessor,
actionRender
}: MobileListProps<T>) => {
const renderCell = (item: T, col: Column<T>) => {
if (typeof col.accessor === 'function') {
return col.accessor(item);
}
return item[col.accessor] as React.ReactNode;
};
const getTitle = (item: T) => {
if (!titleAccessor) return undefined;
if (typeof titleAccessor === 'function') return titleAccessor(item) as string;
return item[titleAccessor] as string;
};
const getSubtitle = (item: T) => {
if (!subtitleAccessor) return undefined;
if (typeof subtitleAccessor === 'function') return subtitleAccessor(item) as string;
return item[subtitleAccessor] as string;
};
return (
<>
{/* Mobile View: Cards */}
<div className="md:hidden flex flex-col gap-4">
{data.map((item, index) => (
<Card
key={keyExtractor(item) || `mobile-${index}`}
onClick={() => onItemClick?.(item)}
title={getTitle(item)}
description={getSubtitle(item)}
actions={actionRender?.(item)}
className="active:bg-surface-hover"
>
<div className="space-y-2 mt-2">
{columns.map((col, colIndex) => {
// Skip if it's the title or subtitle to avoid duplication (optional logic)
return (
<div key={colIndex} className="flex justify-between text-sm py-1 border-b border-dashed border-border last:border-0">
<span className="font-medium text-text-secondary">{col.header}:</span>
<span className="text-text-main text-right">{renderCell(item, col)}</span>
</div>
);
})}
</div>
</Card>
))}
</div>
{/* Desktop View: Table */}
<div className="hidden md:block overflow-hidden rounded-2xl border border-border/40 shadow-soft bg-surface">
<table className="min-w-full divide-y divide-border/40">
<thead className="bg-surface-soft/50">
<tr>
{columns.map((col, idx) => (
<th
key={idx}
className={`px-4 py-5 text-left text-[10px] font-bold text-text-muted uppercase tracking-[0.2em] ${col.className || ''}`}
>
{col.header}
</th>
))}
{actionRender && <th className="px-4 py-5 text-right text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">Ações</th>}
</tr>
</thead>
<tbody className="bg-surface divide-y divide-border/40">
{data.map((item, index) => (
<tr
key={keyExtractor(item) || `row-${index}`}
onClick={() => onItemClick?.(item)}
className="hover:bg-primary/[0.02] transition-colors cursor-pointer group"
>
{columns.map((col, idx) => (
<td key={idx} className="px-4 py-5 whitespace-nowrap text-sm text-text-main font-medium group-hover:text-primary">
{renderCell(item, col)}
</td>
))}
{actionRender && (
<td className="px-4 py-5 whitespace-nowrap text-right text-sm font-medium">
{actionRender(item)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</>
);
};

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
maxWidth?: string;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, maxWidth = 'max-w-2xl' }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className={`bg-surface rounded-2xl shadow-[var(--shadow-premium)] w-full ${maxWidth} max-h-[90vh] flex flex-col animate-in fade-in zoom-in duration-200 border border-border/40 relative`}>
<div className="flex justify-between items-center p-5 border-b border-border">
<h2 className="text-xl font-bold text-text-main">{title}</h2>
<button onClick={onClose} className="p-2 text-text-secondary hover:bg-surface-hover hover:text-text-main rounded-lg transition-colors" aria-label="Fechar modal">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 overflow-y-auto">
{children}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,199 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNotifications } from '../hooks/useNotifications';
import { Bell, Check, Info, AlertTriangle, AlertCircle, CheckCircle, Trash2, Archive } from 'lucide-react';
import type { NotificationType, INotification } from '../types';
import { ArchivedNotificationsModal } from './ArchivedNotificationsModal';
const NotificationBell: React.FC = () => {
const {
unreadCount,
notifications,
markAsRead,
markAllAsRead,
clearAll,
archiveNotification,
deleteNotification
} = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
const [confirmActionId, setConfirmActionId] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// Close popover when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [popoverRef]);
const getIcon = (type: NotificationType) => {
switch (type) {
case 'info': return <Info size={16} className="text-blue-500" />;
case 'warning': return <AlertTriangle size={16} className="text-yellow-500" />;
case 'error': return <AlertCircle size={16} className="text-red-500" />;
case 'success': return <CheckCircle size={16} className="text-green-500" />;
default: return <Info size={16} className="text-gray-500" />;
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
return (
<div className="relative" ref={popoverRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`p-2 rounded-full transition-colors relative ${isOpen ? 'bg-zinc-800 text-white' : 'hover:bg-zinc-800 text-zinc-400'
}`}
title="Notificações"
>
<div className={unreadCount > 0 ? "animate-pulse text-red-500" : ""}>
<Bell size={20} />
</div>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute left-0 bottom-full mb-2 w-80 sm:w-96 bg-zinc-900 border border-zinc-800 rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-3 border-b border-zinc-800 flex justify-between items-center bg-zinc-900">
<h3 className="text-sm font-semibold text-zinc-100">Notificações</h3>
<div className="flex items-center gap-3">
{unreadCount > 0 && (
<button
onClick={() => markAllAsRead()}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1"
title="Marcar todas como lidas"
>
<Check size={12} /> Lidas
</button>
)}
{notifications.length > 0 && (
<button
onClick={() => {
if (window.confirm('Deseja limpar todas as notificações?')) {
clearAll();
}
}}
className="text-xs text-zinc-500 hover:text-red-400 transition-colors flex items-center gap-1"
title="Limpar tudo"
>
<Trash2 size={12} /> Limpar
</button>
)}
<button
onClick={() => setIsArchiveModalOpen(true)}
className="text-xs text-zinc-500 hover:text-zinc-200 transition-colors flex items-center gap-1"
title="Ver Arquivadas"
>
<Archive size={12} /> Log
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-8 text-center text-zinc-500 text-sm">
Nenhuma notificação.
</div>
) : (
<ul>
{notifications.map((notification: INotification) => (
<li
key={notification._id}
className={`p-3 border-b border-zinc-800 hover:bg-zinc-800/50 transition-colors flex gap-3 ${!notification.isRead ? 'bg-zinc-800/20' : ''
}`}
>
<div className="mt-1 flex-shrink-0">
{getIcon(notification.type)}
</div>
<div className="flex-1">
<div className="flex justify-between items-start mb-1">
<span className={`text-sm font-medium ${!notification.isRead ? 'text-white' : 'text-zinc-400'}`}>
{notification.title}
</span>
<span className="text-[10px] text-zinc-500 whitespace-nowrap ml-2">
{formatDate(notification.createdAt)}
</span>
</div>
<div className="flex justify-between items-center mt-2">
{!notification.isRead ? (
<button
onClick={() => markAsRead(notification._id)}
className="text-[10px] text-blue-400 hover:text-blue-300 flex items-center gap-1 font-semibold"
>
Marcar como lida
</button>
) : <div />}
<button
onClick={() => setConfirmActionId(notification._id)}
className="text-zinc-500 hover:text-red-400 transition-colors p-1"
title="Opções de exclusão"
>
<Trash2 size={14} />
</button>
</div>
{/* Prompt de Arquivar/Excluir */}
{confirmActionId === notification._id && (
<div className="absolute inset-0 bg-zinc-900/95 flex flex-col items-center justify-center p-4 z-10 rounded-lg animate-in fade-in">
<p className="text-xs font-bold text-white mb-3 text-center">O que deseja fazer com esta mensagem?</p>
<div className="flex gap-2 w-full">
<button
onClick={() => {
archiveNotification(notification._id);
setConfirmActionId(null);
}}
className="flex-1 px-2 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-white rounded text-[10px] font-bold transition-colors"
>
Arquivar (Log)
</button>
<button
onClick={() => {
deleteNotification(notification._id);
setConfirmActionId(null);
}}
className="flex-1 px-2 py-1.5 bg-red-600/20 hover:bg-red-600/40 text-red-500 rounded text-[10px] font-bold border border-red-500/30 transition-colors"
>
Apagar Devez
</button>
</div>
<button
onClick={() => setConfirmActionId(null)}
className="mt-3 text-[10px] text-zinc-500 hover:text-zinc-300 underline"
>
Cancelar
</button>
</div>
)}
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
<ArchivedNotificationsModal
isOpen={isArchiveModalOpen}
onClose={() => setIsArchiveModalOpen(false)}
/>
</div>
);
};
export default NotificationBell;

View File

@@ -0,0 +1,112 @@
import React, { useRef, useState } from 'react';
import { Camera, X } from 'lucide-react';
import api from '../services/api';
interface PhotoUploadProps {
photos: string[];
onPhotosChange: (url: string) => void;
onRemovePhoto: (index: number) => void;
}
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ photos, onPhotosChange, onRemovePhoto }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validation: Max 500KB
if (file.size > 500 * 1024) {
alert('A foto deve ter no máximo 500KB.');
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
// Validation: JPG only
if (file.type !== 'image/jpeg' && file.type !== 'image/jpg') {
alert('Apenas fotos no formato JPG são permitidas.');
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
setUploading(true);
const formData = new FormData();
formData.append('photo', file);
try {
const response = await api.post('/inspections/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
onPhotosChange(response.data.url);
} catch (error) {
console.error('Error uploading photo:', error);
alert('Erro ao enviar foto. Verifique se o tamanho é menor que 500KB.');
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
return (
<div className="space-y-3">
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1">Fotos da Inspeção</span>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{photos.map((photo, index) => (
<div key={index} className="relative group aspect-square rounded-xl overflow-hidden border border-border/40 bg-surface-soft">
<img
src={photo}
alt={`Evidência ${index + 1}`}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
onClick={() => onRemovePhoto(index)}
className="p-2 bg-error text-white rounded-full hover:bg-error/90 transition-colors"
aria-label={`Remover foto ${index + 1}`}
title={`Remover foto ${index + 1}`}
>
<X size={16} />
</button>
</div>
</div>
))}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="aspect-square flex flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-border/60 hover:border-primary/50 bg-surface hover:bg-surface-hover transition-all text-text-muted hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Adicionar foto"
title="Adicionar foto (JPG, Max 500KB)"
>
{uploading ? (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
) : (
<>
<Camera size={24} className="mb-1" />
<span className="text-xs font-bold uppercase">Adicionar</span>
<span className="text-[9px] font-medium opacity-70">Máx 500kb (JPG)</span>
</>
)}
</button>
</div>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/jpeg, image/jpg"
onChange={handleFileChange}
aria-label="Selecionar arquivo de foto"
title="Selecionar arquivo de foto"
/>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/useAuth';
import type { UserRole } from '../types';
import { RefreshCw } from 'lucide-react';
interface ProtectedRouteProps {
children: React.ReactNode;
allowedRoles?: UserRole[];
requireEdit?: boolean;
redirectTo?: string;
}
/**
* ProtectedRoute component that restricts access based on user role
* @param allowedRoles - Array of roles that can access the route
* @param requireEdit - If true, only users who can edit (not guests) can access
* @param redirectTo - Where to redirect if access is denied (default: '/')
*/
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
allowedRoles,
requireEdit = false,
redirectTo = '/',
}) => {
const { appUser, isLoading, canEdit } = useAuth();
// Show loading state
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<RefreshCw size={32} className="animate-spin text-primary" />
</div>
);
}
// Check role-based access
if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) {
return <Navigate to={redirectTo} replace />;
}
// Check edit permission
if (requireEdit && !canEdit()) {
return <Navigate to={redirectTo} replace />;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { twMerge } from 'tailwind-merge';
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { label: string; value: string | number }[];
}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(({ className, label, error, options, ...props }, ref) => {
return (
<div className="flex flex-col gap-1 w-full">
{label && <label className="text-[10px] font-bold text-primary dark:text-primary-light uppercase tracking-[0.15em] ml-1 mb-1">{label}</label>}
<select
ref={ref}
className={twMerge(
'flex h-12 w-full rounded-xl border px-4 py-2 text-sm transition-all font-medium shadow-sm appearance-none outline-none',
'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)]',
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-error focus:ring-error/10 focus:border-error',
className
)}
{...props}
>
<option value="">-- Selecione --</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <span className="text-sm text-error">{error}</span>}
</div>
);
});

View File

@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { X, Send, Trash2 } from 'lucide-react';
import api from '../services/api';
interface SendMessageModalProps {
isOpen: boolean;
onClose: () => void;
recipientId: string;
recipientName: string;
existingMessage?: {
id: string;
message: string;
};
onMessageSent: () => void;
}
export const SendMessageModal: React.FC<SendMessageModalProps> = ({
isOpen,
onClose,
recipientId,
recipientName,
existingMessage,
onMessageSent,
}) => {
const [message, setMessage] = useState(existingMessage?.message || '');
const [isSending, setIsSending] = useState(false);
if (!isOpen) return null;
const handleSend = async () => {
if (!message.trim()) {
alert('Digite uma mensagem primeiro');
return;
}
setIsSending(true);
try {
await api.post('/messages', {
toUserId: recipientId,
message: message.trim(),
});
alert('Mensagem enviada com sucesso!');
onMessageSent();
onClose();
} catch (error) {
console.error('Error sending message:', error);
alert('Erro ao enviar mensagem');
} finally {
setIsSending(false);
}
};
const handleDelete = async () => {
if (!existingMessage?.id) return;
if (!confirm('Deseja realmente deletar esta mensagem?')) return;
setIsSending(true);
try {
await api.delete(`/messages/${existingMessage.id}`);
alert('Mensagem deletada com sucesso!');
onMessageSent();
onClose();
} catch (error) {
console.error('Error deleting message:', error);
alert('Erro ao deletar mensagem');
} finally {
setIsSending(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-surface rounded-2xl shadow-2xl max-w-md w-full border border-border/40 animate-in slide-in-from-bottom-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border/40">
<div>
<h2 className="text-lg font-bold text-text-main">
{existingMessage ? 'Editar Mensagem' : 'Enviar Mensagem'}
</h2>
<p className="text-sm text-text-muted mt-1">
Para: <span className="font-semibold text-primary">{recipientName}</span>
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-surface-hover rounded-lg transition-colors"
>
<X size={20} className="text-text-muted" />
</button>
</div>
{/* Body */}
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-semibold text-text-main mb-2">
Mensagem
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
maxLength={255}
rows={4}
className="w-full px-4 py-3 bg-surface-soft border border-border/40 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/50 text-text-main resize-none"
placeholder="Digite sua mensagem (máximo 255 caracteres)..."
autoFocus
/>
<div className="text-xs text-text-muted text-right mt-1">
{message.length}/255 caracteres
</div>
</div>
{existingMessage && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3">
<p className="text-xs text-amber-600 dark:text-amber-400">
Você enviou uma mensagem para este usuário. Esta ação vai substituir a mensagem anterior (se ainda não foi lida).
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border/40 bg-surface-soft/50">
{existingMessage && (
<button
onClick={handleDelete}
disabled={isSending}
className="flex items-center gap-2 px-4 py-2 bg-error/10 hover:bg-error/20 text-error rounded-lg font-semibold transition-colors disabled:opacity-50"
>
<Trash2 size={16} />
Deletar
</button>
)}
<div className={`flex items-center gap-2 ${existingMessage ? '' : 'ml-auto'}`}>
<button
onClick={onClose}
className="px-4 py-2 hover:bg-surface-hover rounded-lg font-semibold text-text-secondary transition-colors"
>
Cancelar
</button>
<button
onClick={handleSend}
disabled={isSending || !message.trim()}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={16} />
{isSending ? 'Enviando...' : 'Enviar'}
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,210 @@
import React from 'react';
import { usePresence } from '../hooks/usePresence';
import { useAuth } from '../context/useAuth';
import { useOrganization } from '@clerk/clerk-react';
import { SendMessageModal } from './SendMessageModal';
import api from '../services/api';
interface OrganizationMember {
_id: string;
name: string;
email: string;
clerkUserId: string;
role: string;
}
interface PendingMessage {
_id: string;
message: string;
toUser: {
name: string;
email: string;
};
}
export const TeamPresence: React.FC = () => {
const { activeUsers } = usePresence();
const { appUser } = useAuth();
const { organization } = useOrganization();
const [allMembers, setAllMembers] = React.useState<OrganizationMember[]>([]);
const [pendingMessages, setPendingMessages] = React.useState<PendingMessage[]>([]);
const [selectedUser, setSelectedUser] = React.useState<{ id: string; name: string } | null>(null);
const [isModalOpen, setIsModalOpen] = React.useState(false);
console.log('TeamPresence rendered');
console.log('appUser:', appUser);
console.log('organization:', organization);
console.log('activeUsers:', activeUsers);
console.log('allMembers:', allMembers);
// Fetch all organization members
React.useEffect(() => {
const fetchMembers = async () => {
console.log('Fetching members...');
try {
const response = await api.get<OrganizationMember[]>('/users');
console.log('Members fetched:', response.data);
setAllMembers(response.data);
} catch (error) {
console.error('Error fetching members:', error);
}
};
if (organization?.id) {
console.log('Organization ID exists, fetching members');
fetchMembers();
// Refresh every minute
const interval = setInterval(fetchMembers, 60000);
return () => clearInterval(interval);
} else {
console.log('No organization ID, skipping fetch');
}
}, [organization?.id]);
// Fetch pending messages
React.useEffect(() => {
const fetchPendingMessages = async () => {
try {
const response = await api.get<PendingMessage[]>('/messages/pending');
setPendingMessages(response.data);
} catch (error) {
console.error('Error fetching pending messages:', error);
}
};
if (organization?.id) {
fetchPendingMessages();
const interval = setInterval(fetchPendingMessages, 30000);
return () => clearInterval(interval);
}
}, [organization?.id]);
console.log('Rendering with allMembers.length:', allMembers.length);
if (allMembers.length === 0) {
console.log('No members, returning null');
return null;
}
// Create a Set of active user IDs for fast lookup
const activeUserIds = new Set(activeUsers.map(u => u.clerkId));
// Create a map of pending messages by recipient ID
const pendingMessagesByRecipient = new Map(
pendingMessages.map(msg => [msg.toUser?.email, msg])
);
const handleMemberClick = (member: OrganizationMember) => {
if (member.clerkUserId === appUser?.clerkId) {
return; // Don't allow messaging yourself
}
setSelectedUser({ id: member.clerkUserId, name: member.name });
setIsModalOpen(true);
};
const handleModalClose = () => {
setIsModalOpen(false);
setSelectedUser(null);
};
const handleMessageSent = async () => {
// Refresh pending messages
try {
const response = await api.get<PendingMessage[]>('/messages/pending');
setPendingMessages(response.data);
} catch (error) {
console.error('Error refreshing pending messages:', error);
}
};
const getExistingMessage = (member: OrganizationMember) => {
const pending = pendingMessagesByRecipient.get(member.email);
return pending ? { id: pending._id, message: pending.message } : undefined;
};
return (
<>
<div className="px-6 py-3">
<div className="mb-2">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">
Equipe ({activeUsers.length}/{allMembers.length} online)
</span>
</div>
<div className="flex flex-wrap gap-2">
{allMembers.map((member) => {
const isOnline = activeUserIds.has(member.clerkUserId);
const isCurrentUser = member.clerkUserId === appUser?.clerkId;
const hasPendingMessage = pendingMessagesByRecipient.has(member.email);
return (
<div
key={member._id}
className="relative group"
onClick={() => !isCurrentUser && handleMemberClick(member)}
>
<div className={`
w-8 h-8 rounded-full border-2 text-xs font-bold flex items-center justify-center uppercase shadow-sm
transition-all duration-300
${isOnline
? 'bg-primary/20 border-primary text-primary ring-2 ring-primary/20 shadow-primary/20'
: 'bg-surface-soft border-border/30 text-text-muted/40 grayscale opacity-40'
}
${isCurrentUser ? 'ring-2 ring-amber-500 cursor-default' : 'cursor-pointer hover:scale-110'}
${hasPendingMessage ? 'ring-2 ring-blue-500' : ''}
`}>
{member.name.charAt(0)}
</div>
{/* Online indicator */}
{isOnline && (
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-surface animate-pulse" />
)}
{/* Pending message indicator */}
{hasPendingMessage && !isCurrentUser && (
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-blue-500 ring-2 ring-surface" title="Mensagem pendente" />
)}
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1.5 bg-surface border border-border/40 shadow-xl rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
<div className="text-xs">
<div className="font-bold text-text-main flex items-center gap-2">
{member.name}
{isCurrentUser && <span className="text-amber-500 text-[10px]">(Você)</span>}
</div>
<div className="text-text-muted text-[10px] mt-0.5">{member.email}</div>
<div className={`text-[10px] mt-1 font-semibold ${isOnline ? 'text-green-500' : 'text-text-muted'}`}>
{isOnline ? '🟢 Online' : '⚫ Offline'}
</div>
{!isCurrentUser && (
<div className="text-blue-400 text-[10px] mt-1">
💬 Clique para enviar mensagem
</div>
)}
{hasPendingMessage && (
<div className="text-blue-400 text-[10px] mt-1 font-semibold">
Mensagem pendente
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Message Modal */}
{selectedUser && (
<SendMessageModal
isOpen={isModalOpen}
onClose={handleModalClose}
recipientId={selectedUser.id}
recipientName={selectedUser.name}
existingMessage={getExistingMessage(allMembers.find(m => m.clerkUserId === selectedUser.id)!)}
onMessageSent={handleMessageSent}
/>
)}
</>
);
};

View File

@@ -0,0 +1,617 @@
import React, { useState, useMemo } from 'react';
import { X, Search, BookOpen, FileText, ChevronRight, ChevronDown, ExternalLink, Wrench, Droplets, Thermometer, CheckSquare, Shield, Layers, Gauge, Ruler, Calculator } from 'lucide-react';
interface TechnicalManualProps {
isOpen: boolean;
onClose: () => void;
}
interface ManualSection {
id: string;
title: string;
icon: React.ElementType;
content: React.ReactNode;
keywords: string[];
}
export const TechnicalManual: React.FC<TechnicalManualProps> = ({ isOpen, onClose }) => {
const [searchTerm, setSearchTerm] = useState('');
const [activeSection, setActiveSection] = useState<string | null>('intro');
const [showIndex, setShowIndex] = useState(false);
// Seções do Manual
const sections: ManualSection[] = useMemo(() => [
{
id: 'intro',
title: 'Introdução ao GPI',
icon: BookOpen,
keywords: ['introdução', 'gpi', 'sistema', 'gestão', 'pintura', 'industrial'],
content: (
<div className="space-y-6">
<img
src="/GPI-processos_geral.png"
alt="Processos GPI"
className="w-full rounded-2xl border border-border/40 shadow-lg"
/>
<h3 className="text-xl font-black text-primary">Bem-vindo ao GPI</h3>
<p className="text-text-secondary leading-relaxed">
O <strong>GPI (Gestão de Pintura Industrial)</strong> é um sistema completo para gerenciamento de obras,
projetos e processos de pintura industrial em estruturas metálicas. O sistema permite controlar desde
a preparação da superfície até a inspeção final de aderência e espessura.
</p>
<div className="p-4 bg-primary/5 rounded-xl border border-primary/20">
<h4 className="font-bold text-primary mb-2">Módulos Disponíveis:</h4>
<ul className="space-y-2 text-sm text-text-secondary">
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Obras & Projetos:</strong> Gestão de projetos e cronogramas</li>
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Peças:</strong> Cadastro e acompanhamento de peças</li>
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Esquemas:</strong> Definição de esquemas de pintura</li>
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Inspeções:</strong> Registro e controle de inspeções</li>
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Biblioteca Técnica:</strong> Fichas técnicas de tintas</li>
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Estudo de Rendimento:</strong> Cálculo de consumo de tintas</li>
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Calculadora:</strong> Utilitários técnicos e conversões</li>
</ul>
</div>
</div>
)
},
{
id: 'calculator',
title: 'Calculadora',
icon: Calculator,
keywords: ['calculadora', 'conversão', 'espessura', 'consumo', 'custo', 'bicos', 'airless', 'ambiente'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">Ferramentas & Cálculos</h3>
<p className="text-text-secondary leading-relaxed">
O módulo de Calculadora oferece um conjunto de utilitários técnicos essenciais para o dia a dia
do inspetor e gestor de pintura, centralizando conversões e cálculos complexos.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">1. Conversões</h4>
<p className="text-sm text-text-secondary">
Conversor preciso entre unidades comuns na indústria:
<br /> Microns (μm) Mils (milésimos de polegada)
<br /> PSI Bar (Pressão)
<br /> Celsius (°C) Fahrenheit (°F)
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">2. Espessura de Película</h4>
<p className="text-sm text-text-secondary">
Calculadoras para determinar:
<br /> <strong>EPS Estimada:</strong> Baseado na EPU e % Sólidos.
<br /> <strong>EPU Necessária:</strong> Quanto aplicar úmido para atingir a espessura seca desejada.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">3. Consumo & Custo</h4>
<p className="text-sm text-text-secondary">
Ferramenta rápida para estimar tinta necessária:
<br /> Cálculo por Área (m²) e Espessura.
<br /> Inclusão de fator de perda (Eficiência).
<br /> Estimativa de custo total por demão.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">4. Bicos Airless</h4>
<p className="text-sm text-text-secondary">
Decodificador de códigos de bicos airless (ex: 517):
<br /> <strong>Ângulo do leque:</strong> (1º digito × 10) graus.
<br /> <strong>Vazão/Orifício:</strong> (2 últimos digitos) milésimos.
<br /> Recomendação de uso por tipo de tinta.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40 md:col-span-2">
<h4 className="font-bold text-text-main mb-2">5. Condições Ambientais</h4>
<p className="text-sm text-text-secondary">
Verificação rápida de conformidade climática:
<br /> Cálculo automático do <strong>Ponto de Orvalho</strong> (baseado em Temp e UR).
<br /> Verificação da regra "Temperatura da Superfície {'>'} Ponto de Orvalho + 3°C".
</p>
</div>
</div>
</div>
)
},
{
id: 'yield-study',
title: 'Estudo de Rendimento',
icon: Gauge,
keywords: ['rendimento', 'consumo', 'tinta', 'cálculo', 'peso', 'área', 'litros', 'eficiência'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">Cálculo de Consumo de Tintas</h3>
<p className="text-text-secondary leading-relaxed">
O módulo de Estudo de Rendimento permite calcular com precisão a quantidade de tinta necessária
para um projeto, considerando dois métodos de cálculo: por peso (toneladas) e por área (m²).
</p>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-primary/5 rounded-xl border border-primary/20">
<h4 className="font-bold text-primary mb-2">Cálculo por Peso</h4>
<p className="text-xs text-text-muted">Baseado na taxa histórica (L/t) multiplicada pelo peso em toneladas.</p>
<code className="block mt-2 text-xs bg-surface p-2 rounded">
Consumo = Peso × Taxa × (1 / Eficiência)
</code>
</div>
<div className="p-4 bg-blue-500/5 rounded-xl border border-blue-500/20">
<h4 className="font-bold text-blue-500 mb-2">Cálculo por Área</h4>
<p className="text-xs text-text-muted">Baseado na área e espessura de película seca (DFT).</p>
<code className="block mt-2 text-xs bg-surface p-2 rounded">
Consumo = (Área × DFT) / (SV × 10)
</code>
</div>
</div>
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
<h4 className="font-bold text-amber-600 mb-2"> Importante: Eficiência</h4>
<p className="text-sm text-text-secondary">
A eficiência (ex: 80%) representa as perdas no processo de aplicação. Um fator de perda
é aplicado automaticamente aos cálculos: se eficiência = 80%, o consumo real será 25% maior que o teórico.
</p>
</div>
</div>
)
},
{
id: 'surface-prep',
title: 'Preparação de Superfície',
icon: Wrench,
keywords: ['preparação', 'superfície', 'jateamento', 'limpeza', 'rugosidade', 'abrasivo', 'grau'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">A Etapa Mais Crítica</h3>
<p className="text-text-secondary leading-relaxed">
Estudos científicos demonstram que entre <strong>80% e 90%</strong> do sucesso de um sistema de pintura
depende da qualidade do preparo da superfície metálica.
</p>
<div className="space-y-4">
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">Graus de Limpeza (ISO 8501-1)</h4>
<ul className="space-y-2 text-sm text-text-secondary">
<li><strong>St 2:</strong> Limpeza manual com ferramentas</li>
<li><strong>Sa 2:</strong> Jateamento comercial</li>
<li><strong>Sa 2.5:</strong> Jateamento ao metal quase branco (mais comum)</li>
<li><strong>Sa 3:</strong> Jateamento ao metal branco (máxima limpeza)</li>
</ul>
</div>
<div className="p-4 bg-primary/5 rounded-xl border border-primary/20">
<h4 className="font-bold text-primary mb-2">Perfil de Rugosidade</h4>
<p className="text-sm text-text-secondary">
O perfil mínimo de rugosidade deve ser de <strong>25 μm</strong> para um bom sistema de pintura
(ISO 8503-1, ABNT NBR 10443). Perfil excessivo (acima de 100 μm) pode criar picos frágeis.
</p>
</div>
</div>
</div>
)
},
{
id: 'paint-types',
title: 'Tipos de Tintas',
icon: Droplets,
keywords: ['tinta', 'epóxi', 'poliuretano', 'zinco', 'silicato', 'primer', 'acabamento'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">Características das Tintas</h3>
<div className="space-y-4">
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
<h4 className="font-bold text-blue-600 mb-2">Tintas Epóxi</h4>
<ul className="text-sm text-text-secondary space-y-1">
<li> Alta resistência química e física</li>
<li> Excelente aderência e impermeabilidade</li>
<li> Baixa resistência UV (amarelamento)</li>
<li> Cura: 6-16h para repintura, 7 dias completa</li>
</ul>
</div>
<div className="p-4 bg-green-500/10 rounded-xl border border-green-500/30">
<h4 className="font-bold text-green-600 mb-2">Tintas Poliuretano (PU)</h4>
<ul className="text-sm text-text-secondary space-y-1">
<li> Resistência superior aos raios UV</li>
<li> Mantém cor e brilho por mais tempo</li>
<li> Excelente resistência à abrasão</li>
<li> Cura: 4-24h para repintura</li>
</ul>
</div>
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
<h4 className="font-bold text-amber-600 mb-2">Silicato de Zinco</h4>
<ul className="text-sm text-text-secondary space-y-1">
<li> Proteção catódica ativa</li>
<li> Oxidação preferencial do zinco</li>
<li> Requer UR adequada (não acima de 85%)</li>
<li> Cura muito rápida: 20-30 min</li>
</ul>
</div>
</div>
</div>
)
},
{
id: 'solids-volume',
title: 'Sólidos por Volume (SV)',
icon: Layers,
keywords: ['sólidos', 'volume', 'sv', 'rendimento', 'teórico', 'fórmula', 'cálculo'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">O Parâmetro Mais Importante</h3>
<p className="text-text-secondary leading-relaxed">
O <strong>Sólidos por Volume (SV%)</strong> é a porcentagem do volume de tinta que permanece
como filme sólido após a evaporação completa de todos os solventes.
</p>
<div className="p-6 bg-gradient-to-br from-primary/10 to-primary/5 rounded-2xl border border-primary/30">
<h4 className="font-bold text-primary mb-4">Fórmula do Rendimento Teórico</h4>
<code className="block text-lg bg-surface p-4 rounded-xl text-center font-mono">
Rend. Teórico (m²/L) = (SV% × 10) / EPS (μm)
</code>
<p className="text-xs text-text-muted mt-4">
Exemplo: Tinta com 60% SV aplicada para 50 μm = (60 × 10) / 50 = <strong>12 m²/L</strong>
</p>
</div>
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
<h4 className="font-bold text-amber-600 mb-2">Efeito da Diluição</h4>
<p className="text-sm text-text-secondary">
A diluição reduz o SV efetivo. Uma tinta com 60% SV diluída 15% terá SV efetivo de aproximadamente 51%.
Normas recomendam não diluir acima de 10-20%.
</p>
</div>
</div>
)
},
{
id: 'film-thickness',
title: 'Espessura de Película',
icon: Ruler,
keywords: ['espessura', 'película', 'úmida', 'seca', 'dft', 'wft', 'epu', 'eps', 'microns'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">EPU e EPS</h3>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
<h4 className="font-bold text-blue-600 mb-2">EPU (Película Úmida)</h4>
<p className="text-xs text-text-muted">WFT - Wet Film Thickness</p>
<p className="text-sm text-text-secondary mt-2">
Medida imediatamente após aplicação com "pente de campanha".
</p>
</div>
<div className="p-4 bg-green-500/10 rounded-xl border border-green-500/30">
<h4 className="font-bold text-green-600 mb-2">EPS (Película Seca)</h4>
<p className="text-xs text-text-muted">DFT - Dry Film Thickness</p>
<p className="text-sm text-text-secondary mt-2">
Medida após cura com medidor magnético ou eletrônico.
</p>
</div>
</div>
<div className="p-6 bg-gradient-to-br from-primary/10 to-primary/5 rounded-2xl border border-primary/30">
<h4 className="font-bold text-primary mb-4">Relação EPU EPS</h4>
<code className="block text-sm bg-surface p-4 rounded-xl text-center font-mono">
EPS (μm) = EPU (μm) × [SV (%) × (100 - Diluição %)] / 10000
</code>
<p className="text-xs text-text-muted mt-4">
Exemplo: EPU=150μm, SV=82%, Diluição=20% EPS = 150 × (82 × 80) / 10000 = <strong>98 μm</strong>
</p>
</div>
</div>
)
},
{
id: 'environment',
title: 'Condições Ambientais',
icon: Thermometer,
keywords: ['temperatura', 'umidade', 'orvalho', 'ambiente', 'clima', 'condensação'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">Variáveis Críticas</h3>
<div className="space-y-4">
<div className="p-4 bg-red-500/10 rounded-xl border border-red-500/30">
<h4 className="font-bold text-red-600 mb-2">🌡 Temperatura do Ar</h4>
<p className="text-sm text-text-secondary">
Ideal: <strong>16°C a 30°C</strong>. Abaixo de 16°C a cura é muito lenta.
Acima de 30°C ocorre "spray seco".
</p>
</div>
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
<h4 className="font-bold text-blue-600 mb-2">💧 Umidade Relativa</h4>
<p className="text-sm text-text-secondary">
Ideal: <strong>30% a 60%</strong>. Nunca acima de 80-85%.
Alta umidade pode causar condensação e empolamento.
</p>
</div>
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
<h4 className="font-bold text-amber-600 mb-2">🌫 Ponto de Orvalho</h4>
<p className="text-sm text-text-secondary">
A temperatura da superfície deve estar <strong>no mínimo 3°C acima</strong> do ponto de orvalho
(ISO 8502-4, SSPC).
</p>
</div>
</div>
</div>
)
},
{
id: 'inspection',
title: 'Inspeção e Controle',
icon: CheckSquare,
keywords: ['inspeção', 'controle', 'qualidade', 'aderência', 'medição', 'teste', 'defeitos'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">Controle de Qualidade</h3>
<div className="space-y-4">
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">Medição de Espessura (ASTM D7091)</h4>
<p className="text-sm text-text-secondary">
Mínimo de <strong>15 medições</strong> por área de ~5m².
Resultado deve estar entre 80% e 120% do especificado.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">Testes de Aderência</h4>
<ul className="text-sm text-text-secondary space-y-1">
<li><strong>Pull-Off (ASTM D4541):</strong> Força de tração em MPa</li>
<li><strong>Corte em X (ASTM D3359):</strong> Avaliação visual 0B-5B</li>
</ul>
</div>
<div className="p-4 bg-red-500/10 rounded-xl border border-red-500/30">
<h4 className="font-bold text-red-600 mb-2">Defeitos Comuns (ISO 4628)</h4>
<ul className="text-sm text-text-secondary space-y-1">
<li> Bolhas (blistering) - ar ou solventes aprisionados</li>
<li> Crateras (pinholes) - escape de ar durante cura</li>
<li> Descamação (peeling) - aderência inadequada</li>
<li> Empolamento osmótico - sais contaminantes</li>
</ul>
</div>
</div>
</div>
)
},
{
id: 'standards',
title: 'Normas Técnicas',
icon: Shield,
keywords: ['norma', 'abnt', 'iso', 'sspc', 'nace', 'petrobras', 'padrão', 'regulamento'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">Normas Aplicáveis</h3>
<div className="space-y-4">
<div className="p-4 bg-green-500/10 rounded-xl border border-green-500/30">
<h4 className="font-bold text-green-600 mb-2">ABNT (Brasil)</h4>
<ul className="text-xs text-text-secondary space-y-1">
<li>NBR 10443 - Espessura de película seca</li>
<li>NBR 11003 - Aderência por corte</li>
<li>NBR 14847 - Inspeção de pintura</li>
<li>NBR 15158 - Limpeza química</li>
<li>NBR 16267 - Granulometria de abrasivos</li>
</ul>
</div>
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
<h4 className="font-bold text-blue-600 mb-2">ISO (Internacional)</h4>
<ul className="text-xs text-text-secondary space-y-1">
<li>ISO 12944 - Série completa de pintura anticorrosiva</li>
<li>ISO 8501 - Padrões visuais de limpeza</li>
<li>ISO 8502 - Contaminação superficial</li>
<li>ISO 8503 - Rugosidade de superfície</li>
</ul>
</div>
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
<h4 className="font-bold text-amber-600 mb-2">SSPC / NACE / Petrobras</h4>
<ul className="text-xs text-text-secondary space-y-1">
<li>SSPC-SP 6/10/11 - Graus de limpeza</li>
<li>SSPC-PA 2 - Medição de espessura</li>
<li>PETROBRAS N-9 - Jato abrasivo</li>
<li>PETROBRAS N-13 - Requisitos de pintura</li>
</ul>
</div>
</div>
</div>
)
},
{
id: 'navigation',
title: 'Navegação do App',
icon: BookOpen,
keywords: ['navegação', 'menu', 'tela', 'módulo', 'como usar', 'tutorial'],
content: (
<div className="space-y-6">
<h3 className="text-xl font-black text-primary">Como Usar o GPI</h3>
<div className="space-y-4">
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">1. Obras & Projetos</h4>
<p className="text-sm text-text-secondary">
Ponto de partida. Crie projetos com nome, cliente, datas e ambiente de corrosividade (C1-C5).
Todos os outros módulos se relacionam com as obras cadastradas aqui.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">2. Biblioteca Técnica</h4>
<p className="text-sm text-text-secondary">
Cadastre as fichas técnicas das tintas (PDF). O sistema extrai automaticamente dados como
Sólidos por Volume, rendimento e DFT de referência.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">3. Esquemas de Pintura</h4>
<p className="text-sm text-text-secondary">
Defina sistemas de pintura vinculando tintas da biblioteca em camadas (primer, intermediária, acabamento).
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
<h4 className="font-bold text-text-main mb-2">4. Estudo de Rendimento</h4>
<p className="text-sm text-text-secondary">
Calcule o consumo de tinta baseado em peso (toneladas) ou área (m²).
Selecione a tinta da biblioteca para usar automaticamente os dados técnicos.
</p>
</div>
</div>
</div>
)
}
], []);
// Filtrar seções pela pesquisa
const filteredSections = useMemo(() => {
if (!searchTerm.trim()) return sections;
const term = searchTerm.toLowerCase();
return sections.filter(s =>
s.title.toLowerCase().includes(term) ||
s.keywords.some(k => k.includes(term))
);
}, [sections, searchTerm]);
const activeContent = sections.find(s => s.id === activeSection);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-[100] flex items-center justify-center p-4 animate-in fade-in duration-300">
<div className="bg-surface rounded-3xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col border border-border/50 overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-border/40 bg-gradient-to-r from-primary/10 to-transparent flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-primary flex items-center justify-center text-white shadow-lg shadow-primary/30">
<BookOpen size={24} />
</div>
<div>
<h2 className="text-xl font-black text-text-main">Manual Técnico</h2>
<p className="text-xs text-text-muted">Pintura Industrial & Navegação do App</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* Botão PDF */}
<a
href="/Engenharia_da_Durabilidade_Industrial.pdf"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/10 text-red-500 rounded-xl text-sm font-bold hover:bg-red-500/20 transition-all border border-red-500/30"
>
<FileText size={16} />
<span className="hidden sm:inline">PDF Durabilidade</span>
<ExternalLink size={12} />
</a>
{/* Botão Índice */}
<button
onClick={() => setShowIndex(!showIndex)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold transition-all border ${showIndex
? 'bg-primary text-white border-primary'
: 'bg-surface-soft text-text-main border-border/40 hover:border-primary'
}`}
>
{showIndex ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
Índice
</button>
<button
onClick={onClose}
className="p-2.5 text-text-muted hover:text-text-main hover:bg-surface-soft rounded-xl transition-all"
aria-label="Fechar"
>
<X size={20} />
</button>
</div>
</div>
{/* Search Bar */}
<div className="p-4 border-b border-border/40 shrink-0">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-muted" />
<input
type="text"
placeholder="Buscar no manual... (ex: sólidos, rendimento, espessura)"
className="w-full h-12 bg-surface-soft border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium outline-none"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar / Index */}
<div className={`${showIndex ? 'w-72' : 'w-0'} shrink-0 border-r border-border/40 overflow-hidden transition-all duration-300`}>
<div className="p-4 overflow-y-auto h-full custom-scrollbar">
<div className="space-y-1">
{filteredSections.map((section) => (
<button
key={section.id}
onClick={() => {
setActiveSection(section.id);
if (window.innerWidth < 768) setShowIndex(false);
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all text-left ${activeSection === section.id
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-main'
}`}
>
<section.icon size={18} />
<span className="truncate">{section.title}</span>
</button>
))}
</div>
{filteredSections.length === 0 && (
<div className="text-center py-8 text-text-muted text-sm">
Nenhum resultado para "{searchTerm}"
</div>
)}
</div>
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
{activeContent ? (
<div className="max-w-3xl mx-auto animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-border/40">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<activeContent.icon size={20} />
</div>
<h2 className="text-2xl font-black text-text-main">{activeContent.title}</h2>
</div>
{activeContent.content}
</div>
) : (
<div className="flex items-center justify-center h-full text-text-muted">
Selecione um tópico no índice
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-border/40 bg-surface-soft/50 text-center shrink-0">
<p className="text-xs text-text-muted">
GPI v2.1.0 Manual Técnico baseado em normas ABNT, ISO, SSPC e Petrobras
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import React, { useState, useCallback } from 'react';
import type { ReactNode } from 'react';
import { ShieldAlert } from 'lucide-react';
import { setGlobalToastHandler } from '../utils/toastHandler';
import { ToastContext } from '../context/ToastContext';
import type { Toast } from '../context/ToastContext';
interface ToastProviderProps {
children: ReactNode;
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: Toast['type'] = 'warning') => {
const id = Date.now().toString();
setToasts((prev) => [...prev, { id, message, type }]);
// Auto-remove after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
const showGuestWarning = useCallback(() => {
showToast('Você é um convidado e não possui permissão para esta ação', 'warning');
}, [showToast]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
// Register global handler
React.useEffect(() => {
setGlobalToastHandler(showGuestWarning);
return () => setGlobalToastHandler(() => { });
}, [showGuestWarning]);
return (
<ToastContext.Provider value={{ showToast, showGuestWarning }}>
{children}
{/* Toast Container - Centered */}
{toasts.length > 0 && (
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[100]">
<div className="flex flex-col gap-3">
{toasts.map((toast) => (
<div
key={toast.id}
onClick={() => removeToast(toast.id)}
className="pointer-events-auto animate-in fade-in zoom-in duration-200 bg-gradient-to-r from-amber-500/95 to-orange-500/95 text-white px-8 py-5 rounded-2xl shadow-2xl shadow-amber-500/30 flex flex-col items-center justify-center text-center max-w-sm backdrop-blur-sm cursor-pointer hover:scale-[1.02] transition-transform"
>
<div className="bg-white/20 p-3 rounded-xl mb-3">
<ShieldAlert size={28} />
</div>
<p className="font-bold text-base mb-1">Acesso Restrito</p>
<p className="text-white/90 text-base leading-relaxed">{toast.message}</p>
</div>
))}
</div>
</div>
)}
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,208 @@
import React, { useEffect, useState } from 'react';
import { X, Mail, Check, Trash2 } from 'lucide-react';
import api from '../services/api';
interface UnreadMessage {
_id: string;
message: string;
createdAt: string;
fromUser: {
name: string;
email: string;
};
}
interface UnreadMessagesModalProps {
isOpen: boolean;
onClose: () => void;
}
export const UnreadMessagesModal: React.FC<UnreadMessagesModalProps> = ({
isOpen,
onClose,
}) => {
const [messages, setMessages] = useState<UnreadMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [confirmActionId, setConfirmActionId] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
fetchMessages();
}
}, [isOpen]);
const fetchMessages = async () => {
setIsLoading(true);
try {
const response = await api.get<UnreadMessage[]>('/messages/unread');
setMessages(response.data);
} catch (error) {
console.error('Error fetching messages:', error);
} finally {
setIsLoading(false);
}
};
const markAsRead = async (messageId: string) => {
try {
await api.patch(`/messages/${messageId}/read`);
removeMessage(messageId);
} catch (error) {
console.error('Error marking message as read:', error);
alert('Erro ao marcar mensagem como lida');
}
};
const archiveMessage = async (messageId: string) => {
try {
await api.patch(`/messages/${messageId}/archive`);
removeMessage(messageId);
} catch (error) {
console.error('Error archiving message:', error);
alert('Erro ao arquivar mensagem');
}
};
const deleteMessage = async (messageId: string) => {
try {
await api.delete(`/messages/${messageId}/recipient`);
removeMessage(messageId);
} catch (error) {
console.error('Error deleting message:', error);
alert('Erro ao excluir mensagem');
}
};
const removeMessage = (messageId: string) => {
setMessages(prev => prev.filter(m => m._id !== messageId));
if (messages.length === 1) {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-surface rounded-2xl shadow-2xl max-w-lg w-full border border-border/40 animate-in slide-in-from-bottom-4 max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Mail className="text-primary" size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Mensagens Recebidas</h2>
<p className="text-sm text-text-muted">
{messages.length} {messages.length === 1 ? 'mensagem nova' : 'mensagens novas'}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-surface-hover rounded-lg transition-colors"
>
<X size={20} className="text-text-muted" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{isLoading ? (
<div className="text-center py-8">
<p className="text-text-muted">Carregando mensagens...</p>
</div>
) : messages.length === 0 ? (
<div className="text-center py-12">
<Mail size={48} className="text-text-muted/30 mx-auto mb-4" />
<p className="text-text-muted font-semibold">Nenhuma mensagem nova</p>
<p className="text-text-muted/60 text-sm mt-1">
Você está em dia com suas mensagens!
</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg._id}
className="bg-surface-soft border border-border/40 rounded-xl p-4 hover:shadow-lg transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div>
<div className="font-bold text-text-main">{msg.fromUser.name}</div>
<div className="text-xs text-text-muted">{msg.fromUser.email}</div>
</div>
<div className="text-xs text-text-muted">
{new Date(msg.createdAt).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
<div className="bg-white dark:bg-surface rounded-lg p-3 mb-3">
<p className="text-text-main text-sm leading-relaxed">{msg.message}</p>
</div>
<div className="flex justify-between items-center gap-2 relative">
<button
onClick={() => markAsRead(msg._id)}
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg font-semibold text-sm transition-colors flex-1"
>
<Check size={16} />
Lida
</button>
<button
onClick={() => setConfirmActionId(msg._id)}
className="p-2 bg-surface hover:bg-red-500/10 text-text-muted hover:text-red-500 rounded-lg border border-border/40 transition-all"
title="Apagar ou Arquivar"
>
<Trash2 size={18} />
</button>
{/* Action Prompt */}
{confirmActionId === msg._id && (
<div className="absolute inset-0 bg-surface border border-primary/20 rounded-lg shadow-xl flex flex-col items-center justify-center p-2 z-10 animate-in zoom-in-95 duration-200">
<p className="text-[10px] font-bold text-text-main mb-2">Deseja apagar ou arquivar?</p>
<div className="flex gap-2 w-full">
<button
onClick={() => archiveMessage(msg._id)}
className="flex-1 py-1 bg-surface-hover hover:bg-border/40 text-[10px] font-bold rounded border border-border/40"
>
Arquivar
</button>
<button
onClick={() => deleteMessage(msg._id)}
className="flex-1 py-1 bg-red-600 text-white text-[10px] font-bold rounded"
>
Apagar
</button>
</div>
<button
onClick={() => setConfirmActionId(null)}
className="mt-1 text-[8px] text-text-muted hover:underline"
>
Fechar
</button>
</div>
)}
</div>
</div>
))
)}
</div>
{/* Footer */}
{messages.length > 0 && (
<div className="p-6 border-t border-border/40 bg-surface-soft/50">
<button
onClick={onClose}
className="w-full px-4 py-2 bg-surface-hover hover:bg-border/40 rounded-lg font-semibold text-text-main transition-colors"
>
Fechar
</button>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,322 @@
import React, { useState, useRef } from 'react';
import { Download, Upload, AlertTriangle, CheckCircle, Database, FileJson, Info, RefreshCw } from 'lucide-react';
import api from '../../services/api';
import { useOrganization } from '@clerk/clerk-react';
interface BackupStats {
projects: number;
inspections: number;
applicationRecords: number;
technicalDataSheets: number;
paintingSchemes: number;
parts: number;
instruments: number;
yieldStudies: number;
geometryTypes: number;
stockItems: number;
stockMovements: number;
}
interface BackupValidation {
valid: boolean;
isValidOrganization: boolean;
version: string;
timestamp: string;
organizationId: string;
stats: BackupStats;
message: string;
}
export const BackupRestore: React.FC = () => {
const { organization } = useOrganization();
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [validationResult, setValidationResult] = useState<BackupValidation | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = async () => {
if (!organization) return;
setIsExporting(true);
try {
const response = await api.get('/backup/export', {
responseType: 'blob'
});
// Cria um link de download
const blob = new Blob([response.data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Nome do arquivo com timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
link.download = `backup_${organization.name}_${timestamp}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert('✅ Backup exportado com sucesso!');
} catch (error) {
console.error('Erro ao exportar backup:', error);
alert('❌ Erro ao exportar backup. Verifique o console para mais detalhes.');
} finally {
setIsExporting(false);
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setValidationResult(null);
// Valida o arquivo
try {
const fileContent = await file.text();
const backupData = JSON.parse(fileContent);
const response = await api.post<BackupValidation>('/backup/validate', backupData);
setValidationResult(response.data);
} catch (error) {
console.error('Erro ao validar backup:', error);
alert('❌ Arquivo de backup inválido ou corrompido.');
setSelectedFile(null);
}
};
const handleImport = async () => {
if (!selectedFile || !validationResult?.valid) return;
const confirmed = window.confirm(
'⚠️ ATENÇÃO: Esta ação irá SUBSTITUIR TODOS os dados atuais pelos dados do backup.\n\n' +
'Todos os projetos, inspeções, fichas técnicas e demais informações atuais serão PERMANENTEMENTE EXCLUÍDOS.\n\n' +
'Tem certeza que deseja continuar?'
);
if (!confirmed) return;
const doubleConfirm = window.confirm(
'🔴 ÚLTIMA CONFIRMAÇÃO\n\n' +
'Esta é sua última chance de cancelar. Os dados atuais serão IRRECUPERÁVEIS após esta ação.\n\n' +
'Deseja realmente restaurar o backup?'
);
if (!doubleConfirm) return;
setIsImporting(true);
try {
const fileContent = await selectedFile.text();
const backupData = JSON.parse(fileContent);
await api.post('/backup/import', backupData);
alert('✅ Backup restaurado com sucesso! A página será recarregada.');
window.location.reload();
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } }; message?: string };
console.error('Erro ao importar backup:', error);
alert(`❌ Erro ao restaurar backup: ${err.response?.data?.message || err.message || 'Erro desconhecido'}`);
} finally {
setIsImporting(false);
}
};
const formatDate = (isoString: string) => {
const date = new Date(isoString);
return date.toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
{/* Header Info */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle size={24} className="text-amber-500" />
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-text-main mb-2">Backup e Restauração de Dados</h3>
<p className="text-sm text-text-muted leading-relaxed">
Use esta ferramenta para criar cópias de segurança de todos os dados da organização ou restaurar dados de um backup anterior.
<strong className="text-amber-500"> Os backups são específicos para cada organização e não podem ser restaurados em outras organizações.</strong>
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Export Section */}
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-border/20">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Download size={20} className="text-primary" />
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Exportar Backup</h2>
<p className="text-xs text-text-muted">Baixe todos os dados em formato JSON</p>
</div>
</div>
<div className="space-y-4">
<div className="p-4 bg-surface-soft rounded-xl border border-border/20">
<h3 className="text-sm font-bold text-text-main flex items-center gap-2 mb-2">
<Database size={16} className="text-primary" />
O que será exportado?
</h3>
<ul className="text-xs text-text-muted space-y-1 ml-6 list-disc">
<li>Todos os projetos e suas configurações</li>
<li>Inspeções e registros de aplicação</li>
<li>Fichas técnicas e esquemas de pintura</li>
<li>Peças, geometrias e instrumentos</li>
<li>Estudos de rendimento</li>
<li>Estoque e movimentações</li>
</ul>
</div>
<button
onClick={handleExport}
disabled={isExporting}
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-primary hover:bg-primary-dark text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/20"
>
{isExporting ? (
<>
<RefreshCw size={20} className="animate-spin" />
Gerando Backup...
</>
) : (
<>
<Download size={20} />
Baixar Backup Agora
</>
)}
</button>
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-xl">
<p className="text-xs text-blue-400 flex items-start gap-2">
<Info size={14} className="flex-shrink-0 mt-0.5" />
<span>
O arquivo será salvo no seu computador com a data e hora atual.
Guarde-o em local seguro (pendrive, nuvem, etc).
</span>
</p>
</div>
</div>
</div>
{/* Import Section */}
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-border/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center">
<Upload size={20} className="text-amber-500" />
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Restaurar Backup</h2>
<p className="text-xs text-text-muted">Carregue um arquivo de backup JSON</p>
</div>
</div>
<div className="space-y-4">
<label className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-border/40 rounded-2xl cursor-pointer hover:bg-surface-hover hover:border-primary/50 transition-all group">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<FileJson className="w-12 h-12 text-text-muted group-hover:text-primary transition-colors mb-3" />
<p className="text-sm text-text-main font-bold">
{selectedFile ? selectedFile.name : 'Clique para selecionar o arquivo'}
</p>
<p className="text-xs text-text-muted mt-1">
{selectedFile ? `${(selectedFile.size / 1024).toFixed(2)} KB` : 'Arquivo JSON de backup'}
</p>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".json,application/json"
onChange={handleFileSelect}
disabled={isImporting}
/>
</label>
{validationResult && (
<div className={`p-4 rounded-xl border ${validationResult.valid && validationResult.isValidOrganization
? 'bg-green-500/10 border-green-500/30'
: 'bg-red-500/10 border-red-500/30'
}`}>
<div className="flex items-start gap-3">
{validationResult.valid && validationResult.isValidOrganization ? (
<CheckCircle size={20} className="text-green-400 flex-shrink-0 mt-0.5" />
) : (
<AlertTriangle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-2">
<p className={`text-sm font-bold ${validationResult.valid && validationResult.isValidOrganization
? 'text-green-400'
: 'text-red-400'
}`}>
{validationResult.message}
</p>
{validationResult.valid && (
<div className="text-xs text-text-muted space-y-1">
<p><strong>Data do backup:</strong> {formatDate(validationResult.timestamp)}</p>
<p><strong>Versão:</strong> {validationResult.version}</p>
<div className="mt-2 pt-2 border-t border-border/20">
<p className="font-bold mb-1">Registros no backup:</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span> Projetos: {validationResult.stats.projects}</span>
<span> Inspeções: {validationResult.stats.inspections}</span>
<span> Fichas: {validationResult.stats.technicalDataSheets}</span>
<span> Esquemas: {validationResult.stats.paintingSchemes}</span>
<span> Peças: {validationResult.stats.parts}</span>
<span> Instrumentos: {validationResult.stats.instruments}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)}
<button
onClick={handleImport}
disabled={!validationResult?.valid || !validationResult?.isValidOrganization || isImporting}
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-red-500/20"
>
{isImporting ? (
<>
<RefreshCw size={20} className="animate-spin" />
Restaurando...
</>
) : (
<>
<AlertTriangle size={20} />
Restaurar Backup
</>
)}
</button>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-xl">
<p className="text-xs text-red-400 flex items-start gap-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
<span>
<strong>ATENÇÃO:</strong> Restaurar um backup irá SUBSTITUIR PERMANENTEMENTE todos os dados atuais.
Esta ação não pode ser desfeita!
</span>
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import { useOrganization } from '@clerk/clerk-react';
import { Plus, Pencil, Trash2, Box, RefreshCw } from 'lucide-react';
import { Button } from '../Button';
import { Modal } from '../Modal';
import { Input } from '../Input';
import * as geometryService from '../../services/geometryTypeService';
import type { GeometryType } from '../../types';
export const GeometrySettings: React.FC = () => {
const { organization } = useOrganization();
const [types, setTypes] = useState<GeometryType[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<GeometryType | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
name: '',
efficiencyLoss: '20'
});
useEffect(() => {
if (organization?.id) {
fetchTypes();
}
}, [organization?.id]);
const fetchTypes = async () => {
setLoading(true);
try {
const response = await geometryService.getAllTypes();
setTypes(response.data);
} catch (error) {
console.error('Error fetching geometry types', error);
} finally {
setLoading(false);
}
};
const handleOpenModal = (item?: GeometryType) => {
if (item) {
setEditingItem(item);
setForm({ name: item.name, efficiencyLoss: item.efficiencyLoss.toString() });
} else {
setEditingItem(null);
setForm({ name: '', efficiencyLoss: '20' });
}
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const payload = {
name: form.name,
efficiencyLoss: parseFloat(form.efficiencyLoss) || 0
};
if (editingItem) {
await geometryService.updateType(editingItem.id || editingItem._id!, payload);
} else {
await geometryService.createType(payload);
}
setIsModalOpen(false);
fetchTypes();
} catch (error) {
console.error('Error saving type', error);
alert('Erro ao salvar tipo de geometria');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir? Isso pode afetar peças criadas com este tipo.')) return;
try {
await geometryService.deleteType(id);
fetchTypes();
} catch (error) {
console.error('Error deleting type', error);
}
};
const handleRestoreDefaults = async () => {
if (!confirm('Isso irá apagar todos os tipos atuais e restaurar a lista padrão com 20% de perda. Continuar?')) return;
setLoading(true);
try {
await geometryService.restoreDefaults();
fetchTypes();
} catch (error) {
console.error('Error restoring defaults', error);
alert('Erro ao restaurar padrões');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
<div className="flex justify-between items-center bg-surface-soft/30 p-6 rounded-2xl border border-border/20">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
<Box size={24} className="text-primary" />
</div>
<div>
<h2 className="text-xl font-bold text-text-main">Tipos de Geometria/Peças</h2>
<p className="text-sm text-text-muted">Gerencie a lista padrão de peças e suas perdas de eficiência</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={handleRestoreDefaults} title="Restaurar lista original">
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
</Button>
<Button onClick={() => handleOpenModal()}>
<Plus className="w-5 h-5 mr-2" /> Novo Tipo
</Button>
</div>
</div>
<div className="bg-surface rounded-2xl border border-border/40 overflow-hidden shadow-soft">
{loading ? (
<div className="flex items-center justify-center py-20">
<RefreshCw size={32} className="animate-spin text-primary" />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/40 bg-surface-soft">
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Nome da Geometria</th>
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Perda de Eficiência (%)</th>
<th className="text-right px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{types.map((type) => (
<tr key={type.id || type._id} className="hover:bg-surface-hover transition-colors">
<td className="px-6 py-4 font-semibold text-text-main">{type.name}</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
{type.efficiencyLoss}%
</span>
</td>
<td className="px-6 py-4 text-right flex justify-end gap-2">
<button
onClick={() => handleOpenModal(type)}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-lg transition-all"
title="Editar"
>
<Pencil size={18} />
</button>
<button
onClick={() => handleDelete(type.id || type._id!)}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-lg transition-all"
title="Excluir"
>
<Trash2 size={18} />
</button>
</td>
</tr>
))}
{types.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-12 text-center text-text-muted">
Nenhum tipo cadastrado.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title={editingItem ? 'Editar Tipo' : 'Novo Tipo de Geometria'}>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
name="name"
label="Nome (Ex: Vigas médias)"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
required
/>
<Input
name="efficiencyLoss"
label="Perda de Eficiência (%)"
type="number"
step="0.1"
min="0"
max="100"
value={form.efficiencyLoss}
onChange={e => setForm({ ...form, efficiencyLoss: e.target.value })}
required
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={() => setIsModalOpen(false)} disabled={saving}>Cancelar</Button>
<Button type="submit" disabled={saving}>{saving ? 'Salvando...' : 'Salvar'}</Button>
</div>
</form>
</Modal>
</div>
);
};

View File

@@ -0,0 +1,225 @@
import React from 'react';
import { AdhesionGradeSelect } from '../AdhesionGradeSelect';
import { Input } from '../Input';
import { Select } from '../Select';
import { Droplets, Thermometer, Sun } from 'lucide-react';
interface PaintingFormData {
epsPoints: string[];
adhesionTest: string;
batch?: string;
treatmentExecutor?: string;
stockItemId?: string;
temperature?: string;
relativeHumidity?: string;
period?: string;
partTemperature?: string;
treatmentType?: string;
roughnessReadings?: string[];
}
interface PaintingInspectionFormProps {
formData: PaintingFormData;
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
handleEpsChange: (index: number, value: string) => void;
numericPoints: number[];
stockItems?: any[]; // Using any because StockItem might need to be imported or loosely typed here
handleRoughnessChange: (index: number, value: string) => void;
}
export const PaintingInspectionForm: React.FC<PaintingInspectionFormProps> = ({
formData,
handleChange,
handleEpsChange,
numericPoints,
stockItems = [],
handleRoughnessChange
}) => {
const minEps = numericPoints.length > 0 ? Math.min(...numericPoints) : 0;
const maxEps = numericPoints.length > 0 ? Math.max(...numericPoints) : 0;
const avgEps = numericPoints.length > 0 ? numericPoints.reduce((a, b) => a + b, 0) / numericPoints.length : 0;
return (
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-500">
{/* Batch and Executor Section */}
<div className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Tinta Utilizada</label>
<select
name="stockItemId"
value={formData.stockItemId || ''}
onChange={handleChange}
className="w-full p-3 bg-surface border border-border/40 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium appearance-none"
>
<option value="">Selecione a tinta...</option>
{stockItems.map((item: any) => (
<option key={item.id || item._id} value={item.id || item._id}>
{item.dataSheetId?.name || 'Item sem nome'} - Lote: {item.batchNumber}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Lote / Referência</label>
<input
type="text"
name="batch"
value={formData.batch || ''}
onChange={handleChange}
className="w-full p-3 bg-surface border border-border/40 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
placeholder="Ex: Lote 123"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Executante</label>
<input
type="text"
name="treatmentExecutor"
value={formData.treatmentExecutor || ''}
onChange={handleChange}
className="w-full p-3 bg-surface border border-border/40 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
placeholder="Nome do pintor/equipe"
/>
</div>
</div>
<div className="border border-border/30 rounded-2xl p-4 bg-amber-50/40 shadow-sm relative overflow-hidden group">
<div className="absolute top-0 left-0 w-1 h-full bg-primary opacity-60"></div>
<label className="text-[10px] font-black text-primary uppercase tracking-[0.2em] block mb-4">Medições de EPS (µm) Mínimo 10</label>
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{formData.epsPoints.map((point: string, index: number) => (
<div key={index} className="flex flex-col">
<span className="text-[10px] text-text-muted mb-0.5 ml-1">{index + 1}</span>
<input
type="number"
step="1"
className="w-full p-1.5 text-xs rounded border border-border bg-[var(--input-bg)] text-[var(--input-text)] focus:ring-1 focus:ring-primary outline-none transition-all"
value={point}
onChange={(e) => handleEpsChange(index, e.target.value)}
placeholder="µm"
/>
</div>
))}
</div>
<div className="mt-6 grid grid-cols-3 gap-3 pt-4 border-t border-amber-200/20">
<div className="flex flex-col bg-white p-3 rounded-xl border border-amber-100 shadow-sm transition-transform hover:scale-[1.02]">
<span className="text-[9px] uppercase font-black text-primary tracking-widest">Mínimo</span>
<span className="text-base font-black text-stone-900">{minEps.toFixed(0)} <small className="text-[10px] text-stone-400 font-medium">µm</small></span>
</div>
<div className="flex flex-col bg-white p-3 rounded-xl border border-amber-100 shadow-sm transition-transform hover:scale-[1.02]">
<span className="text-[9px] uppercase font-black text-primary tracking-widest">Máximo</span>
<span className="text-base font-black text-stone-900">{maxEps.toFixed(0)} <small className="text-[10px] text-stone-400 font-medium">µm</small></span>
</div>
<div className="flex flex-col bg-white p-3 rounded-xl border border-primary/30 shadow-sm transition-transform hover:scale-[1.02] bg-gradient-to-br from-white to-amber-50/50">
<span className="text-[9px] uppercase font-black text-primary tracking-widest">Média Geral</span>
<span className="text-base font-black text-primary">{avgEps.toFixed(1)} <small className="text-[10px] opacity-70 font-medium">µm</small></span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border/10 pt-4">
<Select
name="treatmentType"
label="Tipo de Tratamento / Jato"
value={formData.treatmentType || ''}
onChange={handleChange}
options={[
{ label: '-- Selecione --', value: '' },
{ label: 'Jateamento Abrasivo Seco', value: 'dry_abrasive_blasting' },
{ label: 'Hidrojateamento', value: 'water_jetting' },
{ label: 'Limpeza Mecânica (St2/St3)', value: 'mechanical_cleaning' },
{ label: 'Outro', value: 'other' }
]}
/>
<div className="space-y-1">
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Rugosidade Média (Opcional)</label>
<div className="flex gap-2">
{[0, 1, 2].map((i) => (
<input
key={i}
type="number"
className="w-full p-2 text-xs border border-border/40 rounded-lg bg-surface text-center font-bold"
placeholder={`R${i + 1}`}
value={formData.roughnessReadings?.[i] || ''}
onChange={(e) => handleRoughnessChange(i, e.target.value)}
/>
))}
</div>
</div>
</div>
{/* Environmental Conditions */}
<div className="bg-surface-soft border border-border/40 rounded-xl p-4">
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] block mb-3">Condições Ambientais</span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Thermometer className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
<Input
name="temperature"
label="Temperatura (°C)"
type="number"
value={formData.temperature || ''}
onChange={handleChange}
className="pl-10"
/>
</div>
<div className="relative">
<Droplets className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
<Input
name="relativeHumidity"
label="Umidade Relativa (%)"
type="number"
value={formData.relativeHumidity || ''}
onChange={handleChange}
className="pl-10"
/>
</div>
<div className="relative">
<Sun className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
<Select
name="period"
label="Período"
value={formData.period || ''}
onChange={handleChange}
options={[
{ label: 'Manhã', value: 'morning' },
{ label: 'Tarde', value: 'afternoon' },
{ label: 'Noite', value: 'night' }
]}
className="pl-10"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border/10 pt-4">
<div className="relative">
<Thermometer className="absolute left-3 top-1/2 -translate-y-1/2 text-primary/60 w-4 h-4" />
<Input
name="partTemperature"
label="Temperatura das Peças (°C)"
type="number"
value={formData.partTemperature || ''}
onChange={handleChange}
className="pl-10 border-primary/20 bg-primary/5 focus:border-primary"
placeholder="Ex: 25.5"
/>
</div>
<AdhesionGradeSelect
name="adhesionTest"
label="Teste de Aderência"
value={formData.adhesionTest || ''}
onChange={handleChange}
/>
</div>
{numericPoints.length < 10 && numericPoints.length > 0 && (
<p className="text-[10px] text-error text-right italic font-medium">Preencha ao menos 10 medições para registrar.</p>
)}
</div>
);
};

View File

@@ -0,0 +1,157 @@
import React from 'react';
import { Input } from '../Input';
import { Select } from '../Select';
import { Droplets, Thermometer, Sun } from 'lucide-react';
interface SurfaceTreatmentFormProps {
formData: any;
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
handleReadingChange: (index: number, value: string) => void;
}
export const SurfaceTreatmentForm: React.FC<SurfaceTreatmentFormProps> = ({ formData, handleChange, handleReadingChange }) => {
// Calculate stats
const readings = (formData.roughnessReadings || []).map((r: any) => parseFloat(r)).filter((r: number) => !isNaN(r));
const minR = readings.length > 0 ? Math.min(...readings) : 0;
const maxR = readings.length > 0 ? Math.max(...readings) : 0;
const avgR = readings.length > 0 ? readings.reduce((a: number, b: number) => a + b, 0) / readings.length : 0;
return (
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
{/* Header Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input name="batch" label="Lote / Referência" value={formData.batch || ''} onChange={handleChange} required placeholder="Ex: Lote A-123" />
<Input name="treatmentExecutor" label="Executante" value={formData.treatmentExecutor || ''} onChange={handleChange} required placeholder="Nome do Jatista/Empresa" />
</div>
{/* Treatment Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Select
name="treatmentType"
label="Tipo de Tratamento"
value={formData.treatmentType || ''}
onChange={handleChange}
options={[
{ label: 'Jateamento Abrasivo Seco', value: 'dry_abrasive_blasting' },
{ label: 'Hidrojateamento', value: 'water_jetting' },
{ label: 'Limpeza Mecânica (St2/St3)', value: 'mechanical_cleaning' },
{ label: 'Limpeza Manual', value: 'manual_cleaning' },
{ label: 'Outro', value: 'other' }
]}
required
/>
<Select
name="cleaningDegree"
label="Grau de Limpeza"
value={formData.cleaningDegree || ''}
onChange={handleChange}
options={[
{ label: 'Sa 1 (Jato Ligeiro)', value: 'Sa 1' },
{ label: 'Sa 2 (Comercial)', value: 'Sa 2' },
{ label: 'Sa 2½ (Metal Quase Branco)', value: 'Sa 2.5' },
{ label: 'Sa 3 (Metal Branco)', value: 'Sa 3' },
{ label: 'St 2 (Manual)', value: 'St 2' },
{ label: 'St 3 (Mecânica)', value: 'St 3' },
{ label: 'WJ-1', value: 'WJ-1' },
{ label: 'WJ-2', value: 'WJ-2' },
{ label: 'WJ-3', value: 'WJ-3' },
{ label: 'WJ-4', value: 'WJ-4' }
]}
required
/>
<Select
name="flashRust"
label="Flash Rust"
value={formData.flashRust || ''}
onChange={handleChange}
options={[
{ label: 'Ausente', value: 'none' },
{ label: 'Leve (Grau L)', value: 'light' },
{ label: 'Médio (Grau M)', value: 'medium' },
{ label: 'Pesado (Grau H)', value: 'heavy' }
]}
/>
</div>
{/* Environmental Conditions */}
<div className="bg-surface-soft border border-border/40 rounded-xl p-4">
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] block mb-3">Condições Ambientais</span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Thermometer className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
<Input
name="temperature"
label="Temperatura (°C)"
type="number"
value={formData.temperature || ''}
onChange={handleChange}
className="pl-10"
/>
</div>
<div className="relative">
<Droplets className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
<Input
name="relativeHumidity"
label="Umidade Relativa (%)"
type="number"
value={formData.relativeHumidity || ''}
onChange={handleChange}
className="pl-10"
/>
</div>
<div className="relative">
<Sun className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
<Select
name="period"
label="Período"
value={formData.period || ''}
onChange={handleChange}
options={[
{ label: 'Manhã', value: 'morning' },
{ label: 'Tarde', value: 'afternoon' },
{ label: 'Noite', value: 'night' }
]}
className="pl-10"
/>
</div>
</div>
</div>
{/* Roughness Readings */}
<div className="border border-border/30 rounded-2xl p-4 bg-surface-soft shadow-sm">
<label className="text-[10px] font-black text-text-main uppercase tracking-[0.2em] block mb-4">Medições de Rugosidade (µm) 5 Leituras</label>
<div className="grid grid-cols-5 gap-2">
{formData.roughnessReadings.map((reading: string, index: number) => (
<div key={index} className="flex flex-col">
<span className="text-[10px] text-text-muted mb-0.5 ml-1 text-center">{index + 1}</span>
<input
type="number"
className="w-full p-2 text-sm text-center rounded-lg border border-border bg-[var(--input-bg)] text-[var(--input-text)] focus:ring-1 focus:ring-primary outline-none transition-all font-bold"
value={reading}
onChange={(e) => handleReadingChange(index, e.target.value)}
placeholder="-"
/>
</div>
))}
</div>
{/* Stats */}
<div className="mt-4 grid grid-cols-3 gap-3 pt-3 border-t border-border/20">
<div className="flex flex-col items-center p-2 rounded-lg bg-surface border border-border/20">
<span className="text-[9px] uppercase font-bold text-text-muted">Mín</span>
<span className="text-sm font-black text-text-main">{minR.toFixed(0)}</span>
</div>
<div className="flex flex-col items-center p-2 rounded-lg bg-surface border border-border/20">
<span className="text-[9px] uppercase font-bold text-text-muted">Máx</span>
<span className="text-sm font-black text-text-main">{maxR.toFixed(0)}</span>
</div>
<div className="flex flex-col items-center p-2 rounded-lg bg-primary/10 border border-primary/20">
<span className="text-[9px] uppercase font-bold text-primary">Média</span>
<span className="text-sm font-black text-primary">{avgR.toFixed(1)}</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react';
import { Modal } from '../Modal';
import { Select } from '../Select';
import { Button } from '../Button';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Project, PaintingScheme } from '../../types';
interface CloneSchemeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
schemeToClone?: PaintingScheme;
}
export const CloneSchemeModal: React.FC<CloneSchemeModalProps> = ({ isOpen, onClose, onSuccess, schemeToClone }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [targetProjectId, setTargetProjectId] = useState('');
useEffect(() => {
if (isOpen) {
api.get('/projects').then(res => {
// Filter out the project where the scheme currently resides
const validProjects = res.data.filter((p: Project) => p.id !== schemeToClone?.projectId);
setProjects(validProjects);
}).catch(err => console.error("Error loading projects", err));
setTargetProjectId('');
}
}, [isOpen, schemeToClone]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
if (!schemeToClone || !targetProjectId) return;
setLoading(true);
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, projectId, ...schemeData } = schemeToClone;
await api.post('/painting-schemes', {
...schemeData,
projectId: targetProjectId,
name: schemeData.name // Keep same name or add suffix? Usually same name is fine for new project.
});
onSuccess();
onClose();
} catch (error) {
console.error('Error cloning scheme', error);
alert('Erro ao clonar esquema');
} finally {
setLoading(false);
}
};
if (!schemeToClone) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Clonar Esquema de Pintura">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-surface-soft p-4 rounded-lg text-sm text-text-secondary border border-border">
<p className="font-bold text-text-main mb-1">Esquema Original:</p>
<p>{schemeToClone.name}</p>
<p className="text-xs text-text-muted mt-1">{schemeToClone.type} {schemeToClone.manufacturer}</p>
</div>
<Select
name="targetProject"
label="Copiar para a Obra/Projeto"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={targetProjectId}
onChange={(e) => setTargetProjectId(e.target.value)}
required
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading || !targetProjectId}>{loading ? 'Clonando...' : 'Confirmar Cópia'}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,463 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { Trash2, Plus, Box, Calculator } from 'lucide-react';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import { type ApplicationRecord, type Part, type Inspection } from '../../types';
interface CreateControlRecordModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId: string;
initialData?: ApplicationRecord;
availableParts: Part[];
existingRecords?: ApplicationRecord[];
availableBatches?: Inspection[];
}
interface BatchItem {
partId: string;
quantity: number; // This is now WEIGHT in KG
}
export const CreateControlRecordModal: React.FC<CreateControlRecordModalProps> = ({
isOpen, onClose, onSuccess, projectId, initialData, availableParts, existingRecords = [], availableBatches = []
}) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
// Batch Composition State
const [items, setItems] = useState<BatchItem[]>([]);
const [selectedPartId, setSelectedPartId] = useState('');
const [quantity, setQuantity] = useState('');
const [formData, setFormData] = useState({
coatStage: '',
pieceDescription: '',
date: '',
operator: '',
realWeight: '',
volumeUsed: '',
areaPainted: '',
wetThicknessAvg: '',
dryThicknessCalc: '',
method: '',
diluentUsed: '',
notes: ''
});
useEffect(() => {
if (initialData) {
setFormData({
coatStage: initialData.coatStage || '',
pieceDescription: initialData.pieceDescription || '',
date: initialData.date ? new Date(initialData.date).toISOString().split('T')[0] : '',
operator: initialData.operator || '',
realWeight: initialData.realWeight?.toString() || '',
volumeUsed: initialData.volumeUsed?.toString() || '',
areaPainted: initialData.areaPainted?.toString() || '',
wetThicknessAvg: initialData.wetThicknessAvg?.toString() || '',
dryThicknessCalc: initialData.dryThicknessCalc?.toString() || '',
method: initialData.method || '',
diluentUsed: initialData.diluentUsed?.toString() || '',
notes: initialData.notes || ''
});
// Use existing items if available
setItems(initialData.items || []);
} else {
setFormData({
coatStage: '',
pieceDescription: '',
date: '',
operator: '',
realWeight: '',
volumeUsed: '',
areaPainted: '',
wetThicknessAvg: '',
dryThicknessCalc: '',
method: '',
diluentUsed: '',
notes: ''
});
setItems([]);
}
setSelectedPartId('');
setQuantity('');
}, [initialData, isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const getPartBalance = (partId: string): number => {
const part = availableParts.find(p => p.id === partId);
if (!part) return 0;
// Total estimated weight of the part/lot
const totalEstimatedWeight = part.weight || 0;
// Sum of weight already used in OTHER records (exclude current record if editing)
let usedWeight = 0;
existingRecords.forEach(record => {
// If we are editing, ignore the current record's usage from the "existing" sum
// so we don't double count or block the user from keeping the same value.
if (initialData && record.id === initialData.id) return;
const recordItems = record.items || [];
recordItems.forEach(item => {
if (item.partId === partId) {
usedWeight += item.quantity;
}
});
});
// Also subtract weight currently in the staging list (items) BUT exclude the one we might be adding/editing?
// Actually, `items` state reflects the *current* session.
// If we add multiple chunks of the same part in one session (unlikely but possible), we should sum them up.
// For simple validation of "Next Add", we check: (Used + CurrentItems + NewAmount) <= Total
const currentSessionWeight = items
.filter(i => i.partId === partId)
.reduce((sum, i) => sum + i.quantity, 0);
return totalEstimatedWeight - (usedWeight + currentSessionWeight);
};
const addItem = () => {
if (!selectedPartId || !quantity || Number(quantity) <= 0) return;
const weightToAdd = Number(quantity);
const part = availableParts.find(p => p.id === selectedPartId);
if (!part) return;
// Validation Logic
const balance = getPartBalance(selectedPartId);
// Allow a small margin of error (e.g. 1%) or strict?
// User requested: "se extrapolar ... o sistema nao aceita"
if (weightToAdd > balance) {
alert(`Quantidade excede o saldo disponível para esta peça.\n\nEstimado Total: ${part.weight} kg\nSaldo Disponível: ${balance.toFixed(2)} kg`);
return;
}
const newItem = { partId: selectedPartId, quantity: weightToAdd };
const newItems = [...items, newItem];
setItems(newItems);
// Auto-calc totals
updateTotals(newItems);
setSelectedPartId('');
setQuantity('');
};
const removeItem = (index: number) => {
const newItems = items.filter((_, i) => i !== index);
setItems(newItems);
updateTotals(newItems);
};
const updateTotals = (currentItems: BatchItem[]) => {
let totalWeight = 0;
let totalArea = 0;
currentItems.forEach(item => {
const part = availableParts.find(p => p.id === item.partId);
if (part && part.weight && part.weight > 0) {
// Item quantity IS the weight now
const weightUsed = item.quantity;
totalWeight += weightUsed;
// Calculate area proportional to weight used based on part definition
// Area Ratio = Total Area / Total Weight
// Used Area = Weight Used * Ratio
const areaRatio = (part.area || 0) / part.weight;
totalArea += weightUsed * areaRatio;
}
});
setFormData(prev => ({
...prev,
realWeight: totalWeight > 0 ? totalWeight.toFixed(1) : prev.realWeight,
areaPainted: totalArea > 0 ? totalArea.toFixed(1) : prev.areaPainted
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
setLoading(true);
try {
const payload = {
...formData,
projectId,
realWeight: parseFloat(formData.realWeight),
volumeUsed: parseFloat(formData.volumeUsed),
areaPainted: parseFloat(formData.areaPainted),
wetThicknessAvg: parseFloat(formData.wetThicknessAvg),
dryThicknessCalc: parseFloat(formData.dryThicknessCalc),
diluentUsed: parseFloat(formData.diluentUsed),
items: items.map(i => ({ partId: i.partId, quantity: Number(i.quantity) }))
};
if (initialData) {
await api.put(`/application-records/${initialData.id}`, payload);
} else {
await api.post('/application-records', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving record', error);
alert('Erro ao salvar registro');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Lote de Pintura" : "Novo Lote de Pintura"}>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Batch Identification */}
<div className="p-4 bg-surface-soft rounded-xl border border-border/40 space-y-4">
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-border/20">
<Box className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-text-muted uppercase tracking-widest">Identificação do Lote</span>
</div>
{availableBatches && availableBatches.length > 0 ? (
<div className="space-y-2">
<Select
name="batchSelect"
label="Selecionar Lote (Inspeção)"
options={[
{ label: 'Selecione um lote...', value: '' },
...availableBatches.map(b => ({ label: (b.batch || '').split('(')[0].trim(), value: b.id })),
{ label: 'Outro / Manual', value: 'manual' }
]}
onChange={(e) => {
const val = e.target.value;
if (val === 'manual' || val === '') {
setFormData(prev => ({ ...prev, pieceDescription: '' }));
} else {
const selectedBatch = availableBatches.find(b => b.id === val);
if (selectedBatch) {
// Extract "Lote-XX" from "Lote-XX (date...)" if needed, or just use label
// Calculate Avg EPS
let avgEPS = '';
if (selectedBatch.epsPoints && selectedBatch.epsPoints.length > 0) {
// epsPoints are (number | null)[]
const validPoints = selectedBatch.epsPoints.filter(p => p !== null && p > 0) as number[];
if (validPoints.length > 0) {
const sum = validPoints.reduce((a, b) => a + b, 0);
avgEPS = (sum / validPoints.length).toFixed(1);
}
}
setFormData(prev => ({
...prev,
pieceDescription: selectedBatch.batch || '',
dryThicknessCalc: avgEPS
// wetThicknessAvg: ... Need solidsVolume etc.
}));
}
}
}}
value={availableBatches.find(b => b.batch === formData.pieceDescription)?.id || (formData.pieceDescription ? 'manual' : '')}
/>
{((!availableBatches.some(b => b.batch === formData.pieceDescription) && formData.pieceDescription !== '') || (availableBatches.find(b => b.batch === formData.pieceDescription)?.id === undefined && formData.pieceDescription === '') || formData.pieceDescription === '') && (
<div className={availableBatches.some(b => b.batch === formData.pieceDescription) ? 'hidden' : ''}>
<Input
name="pieceDescription"
placeholder="Ou digite manualmente..."
value={formData.pieceDescription}
onChange={handleChange}
/>
</div>
)}
</div>
) : (
<Input
name="pieceDescription"
label="Nome/Número do Lote"
placeholder="Ex: Lote 01/2024 - Estrutura Principal"
value={formData.pieceDescription}
onChange={handleChange}
/>
)}
<div className="grid grid-cols-2 gap-4">
<Select
name="coatStage"
label="Demão Aplicada"
options={[
{ label: 'Primer / Fundo', value: 'primer' },
{ label: 'Intermediária', value: 'intermediate' },
{ label: 'Acabamento', value: 'finish' },
{ label: 'Stripe Coat', value: 'stripe_coat' }
]}
value={formData.coatStage}
onChange={handleChange}
/>
<Input name="date" label="Data de Aplicação" type="date" value={formData.date} onChange={handleChange} />
</div>
</div>
{/* Batch Composition */}
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 space-y-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Calculator className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-text-muted uppercase tracking-widest">Composição do Lote</span>
</div>
<span className="text-[10px] text-text-muted bg-white/50 px-2 py-1 rounded-md">
{items.length} itens adicionados
</span>
</div>
<div className="grid grid-cols-[1fr_80px_auto] gap-2 items-end">
<Select
name="partSelector"
label="Adicionar Peça / Geometria"
options={availableParts.map(p => {
// Calculate balance for display if needed, but keeping it simple for select
return {
label: `${p.description} (Total: ${p.weight}kg)`,
value: p.id
};
})}
value={selectedPartId}
onChange={(e) => setSelectedPartId(e.target.value)}
/>
<Input
name="qty"
label="Qtd (Kg)"
type="number"
min="0.1"
step="0.1"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Button type="button" onClick={addItem} size="sm" className="h-10 w-10 mb-0.5 px-0" title="Adicionar Item" aria-label="Adicionar Item">
<Plus size={18} />
</Button>
</div>
{/* Items List */}
{items.length > 0 && (
<div className="bg-surface rounded-lg border border-border/40 overflow-hidden max-h-40 overflow-y-auto">
<table className="w-full text-xs text-left">
<thead className="bg-surface-soft text-text-muted font-bold uppercase sticky top-0">
<tr>
<th className="px-3 py-2">Peça</th>
<th className="px-3 py-2 text-center">Peso Lançado</th>
<th className="px-3 py-2 text-right">Área Calc.</th>
<th className="w-8"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{items.map((item, idx) => {
const part = availableParts.find(p => p.id === item.partId);
if (!part) return null;
const areaRatio = (part.weight && part.weight > 0) ? ((part.area || 0) / part.weight) : 0;
const areaCalculated = item.quantity * areaRatio;
return (
<tr key={idx} className="hover:bg-surface-hover/50">
<td className="px-3 py-2 truncate max-w-[150px]">{part.description}</td>
<td className="px-3 py-2 text-center font-bold text-primary">{item.quantity.toFixed(1)} kg</td>
<td className="px-3 py-2 text-right text-text-muted">
{areaCalculated.toFixed(2)} m²
</td>
<td className="px-3 py-2 text-right">
<button
type="button"
onClick={() => removeItem(idx)}
className="text-text-muted hover:text-error transition-colors"
title="Remover Item"
aria-label="Remover Item"
>
<Trash2 size={12} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Calculated Results / Manual Override */}
<div className="grid grid-cols-2 gap-4">
<div className="relative">
<Input name="areaPainted" label="Área Total (m²)" type="number" step="0.1" value={formData.areaPainted} onChange={handleChange} />
{items.length > 0 && <div className="absolute top-0 right-0 text-[9px] text-green-600 font-bold bg-green-100 px-1.5 py-0.5 rounded">Calculado</div>}
</div>
<div className="relative">
<Input name="realWeight" label="Peso Total (kg)" type="number" step="0.1" value={formData.realWeight} onChange={handleChange} />
{items.length > 0 && <div className="absolute top-0 right-0 text-[9px] text-green-600 font-bold bg-green-100 px-1.5 py-0.5 rounded">Calculado</div>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="volumeUsed" label="Volume de Tinta Gasto (L)" type="number" step="0.1" value={formData.volumeUsed} onChange={handleChange} />
<Input name="diluentUsed" label="Diluente (L)" type="number" step="0.1" value={formData.diluentUsed} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="operator" label="Pintor Responsável" value={formData.operator} onChange={handleChange} />
<Select
name="method"
label="Método de Aplicação"
options={[
{ label: 'Pistola Airless', value: 'airless' },
{ label: 'Pistola Convencional', value: 'conventional' },
{ label: 'Rolo / Trincha', value: 'roller' },
]}
value={formData.method}
onChange={handleChange}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="wetThicknessAvg" label="Esp. Úmida (μm)" type="number" value={formData.wetThicknessAvg} onChange={handleChange} />
<Input name="dryThicknessCalc" label="Esp. Seca Calc (μm)" type="number" value={formData.dryThicknessCalc} onChange={handleChange} />
</div>
<div className="flex flex-col gap-1 w-full">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
<textarea
name="notes"
aria-label="Observações"
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
value={formData.notes}
onChange={handleChange}
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Edição' : 'Criar Lote')}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,444 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { PaintingInspectionForm } from '../forms/PaintingInspectionForm';
import { SurfaceTreatmentForm } from '../forms/SurfaceTreatmentForm';
import { PhotoUpload } from '../PhotoUpload';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Inspection, ApplicationRecord } from '../../types';
import { Paintbrush, Hammer } from 'lucide-react';
import { clsx } from 'clsx';
import { stockService } from '../../services/stockService';
import type { IInstrument } from '../../types/Instrument';
interface CreateInspectionModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId?: string;
initialData?: Inspection;
availableBatches?: ApplicationRecord[];
existingInspections?: Inspection[];
}
interface InspectionFormData {
date: string;
inspector: string;
pieceDescription: string;
appearance: string;
defects: string;
photos: string[];
applicationRecordId: string;
partTemperature: string;
weightKg: string;
instrumentId: string;
// Painting
epsPoints: string[];
adhesionTest: string;
stockItemId: string;
// Surface Treatment
batch: string;
treatmentExecutor: string;
treatmentType: string;
cleaningDegree: string;
roughnessReadings: string[];
flashRust: string;
temperature: string;
relativeHumidity: string;
period: string;
}
const EMPTY_BATCHES: ApplicationRecord[] = [];
const EMPTY_INSPECTIONS: Inspection[] = [];
export const CreateInspectionModal: React.FC<CreateInspectionModalProps> = ({
isOpen, onClose, onSuccess, projectId, initialData, availableBatches = EMPTY_BATCHES, existingInspections = EMPTY_INSPECTIONS
}) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<{ id: string, name: string }[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState(projectId || '');
const [stockItems, setStockItems] = useState<{ _id: string, rrNumber: string, batchNumber: string, quantity: number, unit: string }[]>([]);
const [instruments, setInstruments] = useState<IInstrument[]>([]);
useEffect(() => {
stockService.getAll().then(data => setStockItems(data));
api.get('/instruments').then(res => setInstruments(res.data.filter((i: IInstrument) => i.status === 'active')));
}, []);
const [type, setType] = useState<'painting' | 'surface_treatment'>('painting');
const [formData, setFormData] = useState<InspectionFormData>({
date: '',
inspector: '',
pieceDescription: '',
appearance: '',
defects: '',
photos: [],
applicationRecordId: '',
partTemperature: '',
weightKg: '',
instrumentId: '',
// Painting
epsPoints: Array(20).fill(''),
adhesionTest: '',
stockItemId: '',
// Surface Treatment
batch: '',
treatmentExecutor: '',
treatmentType: '',
cleaningDegree: '',
roughnessReadings: Array(5).fill(''),
flashRust: '',
temperature: '',
relativeHumidity: '',
period: ''
});
useEffect(() => {
if (!projectId) {
api.get('/projects').then(response => {
setProjects(response.data);
}).catch(err => console.error("Error loading projects", err));
} else {
setSelectedProjectId(projectId);
}
}, [projectId, isOpen]);
useEffect(() => {
if (initialData) {
const initialEps = Array(20).fill('');
if (initialData.epsPoints) {
initialData.epsPoints.forEach((p, i) => { if (i < 20) initialEps[i] = p?.toString() || ''; });
}
const initialRoughness = Array(5).fill('');
if (initialData.roughnessReadings) {
initialData.roughnessReadings.forEach((p, i) => { if (i < 5) initialRoughness[i] = p?.toString() || ''; });
}
setType(initialData.type || 'painting');
setFormData({
date: initialData.date ? new Date(initialData.date).toISOString().split('T')[0] : '',
inspector: initialData.inspector || '',
pieceDescription: initialData.pieceDescription || '',
appearance: initialData.appearance || '',
defects: initialData.defects || '',
photos: initialData.photos || [],
applicationRecordId: initialData.applicationRecordId || '',
epsPoints: initialEps,
adhesionTest: initialData.adhesionTest || '',
stockItemId: typeof initialData.stockItemId === 'object' ? initialData.stockItemId._id : (initialData.stockItemId || ''),
batch: initialData.batch || '',
treatmentExecutor: initialData.treatmentExecutor || '',
treatmentType: initialData.treatmentType || '',
cleaningDegree: initialData.cleaningDegree || '',
roughnessReadings: initialRoughness,
flashRust: initialData.flashRust || '',
temperature: initialData.temperature?.toString() || '',
relativeHumidity: initialData.relativeHumidity?.toString() || '',
period: initialData.period || '',
partTemperature: initialData.partTemperature?.toString() || '',
weightKg: initialData.weightKg?.toString() || '',
instrumentId: typeof initialData.instrumentId === 'object' && initialData.instrumentId ? (initialData.instrumentId as IInstrument)._id : (initialData.instrumentId as string || '')
});
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
} else {
// Auto-calculate next batch number logic
let nextBatch = '';
if (existingInspections && existingInspections.length > 0) {
const nums = existingInspections
.map(i => {
const match = (i.batch || '').match(/Lote-(\d+)/i);
return match ? parseInt(match[1]) : 0;
})
.filter(n => n > 0);
const maxNum = nums.length > 0 ? Math.max(...nums) : 0;
nextBatch = `Lote-${String(maxNum + 1).padStart(2, '0')}`;
} else {
nextBatch = 'Lote-01';
}
// Reset form
setType('painting');
setFormData({
date: new Date().toISOString().split('T')[0],
inspector: '',
pieceDescription: '',
appearance: '',
defects: '',
photos: [],
applicationRecordId: '',
epsPoints: Array(20).fill(''),
adhesionTest: '',
stockItemId: '',
batch: nextBatch, // Auto-filled
treatmentExecutor: '',
treatmentType: '',
cleaningDegree: '',
roughnessReadings: Array(5).fill(''),
flashRust: '',
temperature: '',
relativeHumidity: '',
period: '',
partTemperature: '',
weightKg: '',
instrumentId: ''
});
if (projectId) setSelectedProjectId(projectId);
}
// eslint-disable-next-line
}, [initialData, isOpen, projectId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleEpsChange = (index: number, value: string) => {
const newPoints = [...formData.epsPoints];
newPoints[index] = value;
setFormData({ ...formData, epsPoints: newPoints });
};
const handleRoughnessChange = (index: number, value: string) => {
const newPoints = [...formData.roughnessReadings];
newPoints[index] = value;
setFormData({ ...formData, roughnessReadings: newPoints });
};
const handlePhotoAdd = (url: string) => {
setFormData((prev) => ({ ...prev, photos: [...prev.photos, url] }));
};
const handlePhotoRemove = (index: number) => {
setFormData((prev) => ({ ...prev, photos: prev.photos.filter((_, i) => i !== index) }));
};
// Validation
const numericEps = formData.epsPoints.map(p => parseFloat(p)).filter(p => !isNaN(p));
// const isValidPainting = type === 'painting' && numericEps.length >= 10; // Relaxed
// const isValidTreatment = type === 'surface_treatment' && formData.treatmentType && formData.cleaningDegree; // Relaxed
// Basic validation
const canSubmit = formData.date && formData.inspector && formData.pieceDescription;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
if (!selectedProjectId) {
alert("Selecione um projeto");
return;
}
setLoading(true);
try {
const payload = {
projectId: selectedProjectId,
type,
date: formData.date,
inspector: formData.inspector,
pieceDescription: formData.pieceDescription,
appearance: formData.appearance,
defects: formData.defects,
photos: formData.photos,
applicationRecordId: formData.applicationRecordId || null,
// Fields are sent regardless, backend optionality handles it
epsPoints: formData.epsPoints.map(p => p !== '' ? parseFloat(p) : null),
adhesionTest: formData.adhesionTest,
stockItemId: formData.stockItemId || null,
batch: formData.batch,
treatmentExecutor: formData.treatmentExecutor,
treatmentType: formData.treatmentType,
cleaningDegree: formData.cleaningDegree,
roughnessReadings: formData.roughnessReadings.map(p => p !== '' ? parseFloat(p) : null),
flashRust: formData.flashRust,
temperature: formData.temperature ? parseFloat(formData.temperature) : null,
relativeHumidity: formData.relativeHumidity ? parseFloat(formData.relativeHumidity) : null,
period: formData.period,
partTemperature: formData.partTemperature ? parseFloat(formData.partTemperature) : null,
weightKg: formData.weightKg ? parseFloat(formData.weightKg) : null,
instrumentId: formData.instrumentId || null
};
if (initialData) {
await api.put(`/inspections/${initialData.id}`, payload);
} else {
await api.post('/inspections', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving inspection', error);
alert('Erro ao salvar inspeção');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Inspeção" : "Nova Inspeção"}>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tabs */}
<div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 mb-6">
<button
type="button"
onClick={() => setType('surface_treatment')}
className={clsx(
"flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-bold rounded-lg transition-all",
type === 'surface_treatment'
? "bg-amber-600 text-white shadow-lg shadow-amber-600/20"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Hammer size={16} />
Tratamento
</button>
<button
type="button"
onClick={() => setType('painting')}
className={clsx(
"flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-bold rounded-lg transition-all",
type === 'painting'
? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Paintbrush size={16} />
Pintura
</button>
</div>
{/* Common Fields */}
<div className="space-y-4">
{!projectId && (
<Select
name="projectId"
label="Projeto"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
required
/>
)}
{/* Batch Selection (Painting Only) */}
{type === 'painting' && availableBatches.length > 0 && (
<Select
name="applicationRecordId"
label="Vincular ao Lote de Pintura (Opcional)"
options={[
{ label: 'Selecione um lote...', value: '' },
...availableBatches.map(b => ({
label: `${b.pieceDescription || 'Lote sem nome'} (${b.coatStage})`,
value: b.id
}))
]}
value={formData.applicationRecordId}
onChange={handleChange}
/>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input name="date" label="Data da Inspeção" type="date" value={formData.date} onChange={handleChange} required />
<Input name="inspector" label="Inspetor" value={formData.inspector} onChange={handleChange} required />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input name="pieceDescription" label="Peça/Área Inspecionada" value={formData.pieceDescription} onChange={handleChange} required />
<Input name="weightKg" label="Peso Inspecionado (kg)" type="number" value={formData.weightKg} onChange={handleChange} required placeholder="Ex: 688" />
</div>
<Select
name="instrumentId"
label="Instrumento Utilizado"
options={[
{ label: 'Selecione um instrumento...', value: '' },
...instruments.map(i => ({ label: `${i.name} - ${i.serialNumber} (${i.type})`, value: i._id }))
]}
value={formData.instrumentId}
onChange={handleChange}
/>
</div>
{/* Specific Forms */}
{type === 'painting' ? (
<PaintingInspectionForm
formData={formData}
handleChange={handleChange}
handleEpsChange={handleEpsChange}
numericPoints={numericEps}
stockItems={stockItems}
handleRoughnessChange={handleRoughnessChange}
/>
) : (
<SurfaceTreatmentForm
formData={formData}
handleChange={handleChange}
handleReadingChange={handleRoughnessChange}
/>
)}
{/* Common Footer (Photos, Observation, Status) */}
<div className="space-y-4 pt-4 border-t border-border/40">
<PhotoUpload
photos={formData.photos}
onPhotosChange={handlePhotoAdd}
onRemovePhoto={handlePhotoRemove}
/>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Defeitos / Observações</label>
<textarea
name="defects"
className="flex min-h-[80px] w-full rounded-xl border border-border bg-[var(--input-bg)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all placeholder:text-text-muted/50"
value={formData.defects}
onChange={handleChange}
placeholder="Descreva observações, falhas encontradas ou detalhes adicionais..."
/>
</div>
<Select
name="appearance"
label="Resultado Final"
options={[
{ label: 'Aprovada', value: 'approved' },
{ label: 'Reprovada', value: 'rejected' },
{ label: 'Com Ressalvas', value: 'notes' }
]}
value={formData.appearance}
onChange={handleChange}
required
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading || !canSubmit}>
{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Registrar Inspeção')}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,374 @@
import React from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { PaintingScheme, TechnicalDataSheet } from '../../types';
interface CreatePaintingSchemeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId?: string;
initialData?: PaintingScheme;
}
export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps> = ({ isOpen, onClose, onSuccess, projectId, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = React.useState(false);
const [projects, setProjects] = React.useState<{ id: string, name: string }[]>([]);
const [dataSheets, setDataSheets] = React.useState<TechnicalDataSheet[]>([]);
const [selectedProjectId, setSelectedProjectId] = React.useState(projectId || '');
const [formData, setFormData] = React.useState({
name: '',
type: '',
coat: '',
solidsVolume: '',
yieldTheoretical: '',
epsMin: '',
epsMax: '',
dilution: '',
manufacturer: '',
color: '',
notes: '',
paintConsumption: '',
thinnerConsumption: '',
paintId: '',
thinnerId: '',
thinnerSymbol: '',
colorHex: '#ffffff'
});
React.useEffect(() => {
if (!projectId) {
api.get('/projects').then(response => {
setProjects(response.data);
}).catch(err => console.error("Error loading projects", err));
} else {
setSelectedProjectId(projectId);
}
api.get('/datasheets').then(response => {
console.log('Frontend: Datasheets received:', response.data.length);
setDataSheets(response.data);
}).catch(err => {
console.error("Frontend: Error loading datasheets:", err);
});
}, [projectId, isOpen]);
React.useEffect(() => {
if (initialData) {
setFormData({
name: initialData.name || '',
type: initialData.type || '',
coat: initialData.coat || '',
solidsVolume: initialData.solidsVolume?.toString() || '',
yieldTheoretical: initialData.yieldTheoretical?.toString() || '',
epsMin: initialData.epsMin?.toString() || '',
epsMax: initialData.epsMax?.toString() || '',
dilution: initialData.dilution?.toString() || '',
manufacturer: initialData.manufacturer || '',
color: initialData.color || '',
notes: initialData.notes || '',
paintConsumption: initialData.paintConsumption?.toString() || '',
thinnerConsumption: initialData.thinnerConsumption?.toString() || '',
paintId: typeof initialData.paintId === 'object' ? (initialData.paintId?._id || '') : (initialData.paintId as string) || '',
thinnerId: typeof initialData.thinnerId === 'object' ? (initialData.thinnerId?._id || '') : (initialData.thinnerId as string) || '',
thinnerSymbol: initialData.thinnerSymbol || '',
colorHex: initialData.colorHex || '#ffffff'
});
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
} else {
setFormData({
name: '', type: '', coat: '', solidsVolume: '', yieldTheoretical: '', epsMin: '', epsMax: '',
dilution: '', manufacturer: '', color: '', notes: '',
paintConsumption: '', thinnerConsumption: '', paintId: '', thinnerId: '',
thinnerSymbol: '',
colorHex: '#ffffff'
});
if (projectId) setSelectedProjectId(projectId);
}
}, [initialData, isOpen, projectId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
// Mantém a atualização básica do estado
setFormData(prev => {
const newState = { ...prev, [name]: value };
// Lógica Proativa: Se mudar o NOME do produto no topo, tenta preencher tudo
if (name === 'name' && value) {
const ds = dataSheets.find(d => d.name === value);
if (ds) {
let mappedType = '';
const dsTypeNormalized = ds.type?.toLowerCase() || '';
if (dsTypeNormalized.includes('epóxi')) mappedType = 'epoxy';
else if (dsTypeNormalized.includes('poliuretano')) mappedType = 'polyurethane';
else if (dsTypeNormalized.includes('zinco')) mappedType = 'silicate-zinc';
else if (dsTypeNormalized.includes('acríl')) mappedType = 'acrylic';
else if (dsTypeNormalized.includes('alquíd')) mappedType = 'alkyd';
// Se a tinta tem um redutor na ficha, tenta achar o ID dele na biblioteca
let thinnerId = ''; // Resetar redutor ao trocar a tinta
if (ds.reducer) {
const reducerCode = ds.reducer.trim().toLowerCase();
console.log(`Auto-fill: Looking for reducer "${reducerCode}" for paint "${ds.name}"`);
// Busca flexível: exata ou contém
const matchingReducer = dataSheets.find(d =>
d.name.toLowerCase() === reducerCode ||
d.name.toLowerCase().includes(reducerCode) ||
reducerCode.includes(d.name.toLowerCase())
);
if (matchingReducer) {
thinnerId = matchingReducer._id || matchingReducer.id || '';
console.log(`Auto-fill: Found matching reducer: ${matchingReducer.name}`);
} else {
console.log(`Auto-fill: No matching reducer found in library for "${reducerCode}"`);
}
}
return {
...newState,
type: mappedType || newState.type,
solidsVolume: ds.solidsVolume?.toString() || newState.solidsVolume,
yieldTheoretical: ds.yieldTheoretical?.toString() || newState.yieldTheoretical,
epsMin: ds.dftMin?.toString() || newState.epsMin,
epsMax: ds.dftMax?.toString() || newState.epsMax,
manufacturer: ds.manufacturer || newState.manufacturer,
dilution: ds.dilution?.toString() || newState.dilution,
paintId: ds._id || ds.id || newState.paintId,
thinnerId: thinnerId,
thinnerSymbol: ds.reducer || ''
};
}
}
return newState;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("Submitting form with data:", formData);
if (isGuest()) {
showGuestWarning();
return;
}
if (!selectedProjectId) {
alert("Selecione um projeto");
return;
}
setLoading(true);
try {
const payload = {
...formData,
projectId: selectedProjectId,
solidsVolume: parseInt(formData.solidsVolume),
yieldTheoretical: parseFloat(formData.yieldTheoretical),
epsMin: parseFloat(formData.epsMin),
epsMax: parseFloat(formData.epsMax),
dilution: parseInt(formData.dilution),
paintConsumption: parseFloat(formData.paintConsumption),
thinnerConsumption: parseFloat(formData.thinnerConsumption),
paintId: formData.paintId || null,
thinnerId: formData.thinnerId || null,
thinnerSymbol: formData.thinnerSymbol
};
if (initialData) {
await api.put(`/painting-schemes/${initialData.id}`, payload);
} else {
await api.post('/painting-schemes', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving scheme', error);
alert('Erro ao salvar esquema');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Esquema" : "Novo Esquema / Demão"}>
<form onSubmit={handleSubmit} className="space-y-4">
{!projectId && (
<Select
name="projectId"
label="Projeto"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
required
/>
)}
<Select
name="name"
label="Nome/Descrição (Produto)"
options={[
{ label: 'Outro (Manual)', value: '' },
...dataSheets.map(ds => ({ label: ds.name, value: ds.name }))
]}
value={formData.name}
onChange={handleChange}
required
/>
{formData.name === '' && (
<Input name="name" label="Descrição Manual" placeholder="Ex: Pintura Interna" value={formData.name} onChange={handleChange} required />
)}
<Select
name="coat"
label="Demão (Etapa)"
options={[
{ label: 'Primer / Selador', value: 'Primer' },
{ label: 'Stripe Coat', value: 'Stripe Coat' },
{ label: 'Intermediário', value: 'Intermediario' },
{ label: 'Acabamento', value: 'Acabamento' },
{ label: 'Retoque', value: 'Retoque' }
]}
value={formData.coat}
onChange={handleChange}
required
/>
<div className="grid grid-cols-2 gap-4">
<Select
name="type"
label="Tipo de Tinta"
options={[
{ label: 'Epóxi', value: 'epoxy' },
{ label: 'Poliuretano', value: 'polyurethane' },
{ label: 'Silicato Zinco', value: 'silicate-zinc' },
{ label: 'Acrílica', value: 'acrylic' },
{ label: 'Alquídica', value: 'alkyd' }
]}
value={formData.type}
onChange={handleChange}
/>
<Input name="solidsVolume" label="Sólidos Vol. (%)" type="number" value={formData.solidsVolume} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="yieldTheoretical" label="Rendimento (m²/L)" type="number" step="0.01" value={formData.yieldTheoretical} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="epsMin" label="EPS Mín (μm)" type="number" value={formData.epsMin} onChange={handleChange} />
<Input name="epsMax" label="EPS Máx (μm)" type="number" value={formData.epsMax} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="dilution" label="Diluição (%)" type="number" value={formData.dilution} onChange={handleChange} />
<Input name="manufacturer" label="Fabricante" value={formData.manufacturer} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="color" label="Cor (Munsell/RAL)" placeholder="Ex: N6.5 ou RAL 7035" value={formData.color} onChange={handleChange} />
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Cor Representativa</label>
<div className="flex items-center gap-3 bg-surface-soft/50 p-2 rounded-xl border border-border/40">
<input
type="color"
name="colorHex"
value={formData.colorHex}
onChange={handleChange}
title="Cor Representativa"
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent"
/>
<span className="text-xs font-mono font-bold text-text-muted">{formData.colorHex}</span>
</div>
</div>
</div>
<div className="flex flex-col gap-1 w-full">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
<textarea
name="notes"
aria-label="Observações"
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
value={formData.notes}
onChange={handleChange}
/>
</div>
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mt-4">
<h3 className="text-sm font-bold text-text-main mb-3">Planejamento de Consumo (Opcional)</h3>
<div className="grid grid-cols-2 gap-4">
<Select
name="paintId"
label="Produto (Tinta)"
options={[
{ label: 'Selecione...', value: '' },
...dataSheets.map(ds => ({ label: ds.name, value: ds._id || ds.id || '' }))
]}
value={formData.paintId}
onChange={(e) => {
const selectedPaintId = e.target.value;
setFormData(prev => {
const ds = dataSheets.find(d => (d._id || d.id) === selectedPaintId);
let thinnerId = ''; // Resetar ao trocar tinta
if (ds && ds.reducer) {
const reducerClean = ds.reducer.trim().toLowerCase();
console.log(`Consumption: Looking for reducer "${reducerClean}" for paint ID ${selectedPaintId}`);
// Busca exata ou por inclusão
const matchingThinner = dataSheets.find(d =>
d.name.toLowerCase() === reducerClean ||
d.name.toLowerCase().includes(reducerClean) ||
reducerClean.includes(d.name.toLowerCase())
);
if (matchingThinner) {
thinnerId = matchingThinner._id || matchingThinner.id || '';
console.log(`Consumption: Found reducer match: ${matchingThinner.name}`);
}
}
return { ...prev, paintId: selectedPaintId, thinnerId, thinnerSymbol: ds?.reducer || '' };
});
}}
/>
<Input
name="thinnerSymbol"
label="Redutor (diluente)"
value={formData.thinnerSymbol}
readOnly
placeholder="Preenchido pela tinta"
className="bg-surface-soft/50 font-bold text-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-2">
<Input
name="paintConsumption"
label="Consumo Tinta (L/Kg)"
type="number"
step="0.001"
value={formData.paintConsumption}
onChange={handleChange}
/>
<Input
name="thinnerConsumption"
label="Consumo Diluente (L/Kg)"
type="number"
step="0.001"
value={formData.thinnerConsumption}
onChange={handleChange}
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Adicionar Demão')}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useState } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import api from '../../services/api';
import * as geometryService from '../../services/geometryTypeService';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Part, GeometryType } from '../../types';
interface CreatePartModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId?: string;
initialData?: Part;
}
export const CreatePartModal: React.FC<CreatePartModalProps> = ({ isOpen, onClose, onSuccess, projectId, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<{ id: string, name: string }[]>([]);
const [geometryTypes, setGeometryTypes] = useState<GeometryType[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState(projectId || '');
const [formData, setFormData] = useState({
description: '',
dimensions: '',
weight: '',
type: '',
area: '',
complexity: '',
quantity: '1',
notes: ''
});
useEffect(() => {
if (initialData) {
setFormData({
description: initialData.description || '',
dimensions: initialData.dimensions || '',
weight: initialData.weight?.toString() || '',
type: initialData.type || '',
area: initialData.area?.toString() || '',
complexity: initialData.complexity?.toString() || '',
quantity: initialData.quantity?.toString() || '1',
notes: initialData.notes || ''
});
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
} else {
setFormData({ description: '', dimensions: '', weight: '', type: '', area: '', complexity: '', quantity: '1', notes: '' });
if (projectId) setSelectedProjectId(projectId);
}
}, [initialData, isOpen, projectId]);
useEffect(() => {
if (isOpen) {
if (!projectId) {
api.get('/projects')
.then(res => setProjects(res.data))
.catch(err => console.error("Error fetching projects", err));
}
geometryService.getAllTypes()
.then(res => setGeometryTypes(res.data))
.catch(err => console.error("Error fetching geometry types", err));
}
}, [isOpen, projectId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
const projectToUse = projectId || selectedProjectId;
if (!projectToUse) {
alert("Por favor, selecione um projeto.");
return;
}
setLoading(true);
try {
const payload = {
description: formData.type, // Usar o tipo como descrição
projectId: projectToUse,
dimensions: formData.dimensions || undefined,
weight: formData.weight ? parseFloat(formData.weight) : undefined,
type: formData.type || undefined,
area: formData.area ? parseFloat(formData.area) : undefined,
quantity: formData.quantity ? parseInt(formData.quantity) : 1,
notes: formData.notes || undefined
};
if (initialData) {
await api.put(`/parts/${initialData.id}`, payload);
} else {
await api.post('/parts', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving part', error);
alert('Erro ao salvar peça');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Peça" : "Nova Peça / Geometria"}>
<form onSubmit={handleSubmit} className="space-y-4">
{!projectId && (
<Select
name="projectId"
label="Projeto / Obra"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
required
/>
)}
<Select
name="type"
label="Tipo Geometria"
options={[
{ label: 'Selecione...', value: '' },
...geometryTypes.map(t => ({ label: t.name, value: t.name }))
]}
value={formData.type}
onChange={handleChange}
required
/>
<div className="grid grid-cols-2 gap-4">
<Input name="weight" label="Kg estimado do lote" type="number" step="0.1" value={formData.weight} onChange={handleChange} />
<Input name="area" label="Área Superfície (m²)" type="number" step="0.01" value={formData.area} onChange={handleChange} />
</div>
<div className="flex flex-col gap-1 w-full">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
<textarea
name="notes"
aria-label="Observações"
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
value={formData.notes}
onChange={handleChange}
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Adicionar Peça')}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { FileDown, Plus } from 'lucide-react';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import { CreatePaintingSchemeModal } from './CreatePaintingSchemeModal';
import { ImportSchemeModal } from './ImportSchemeModal';
import type { Project } from '../../types';
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: Project;
}
export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose, onSuccess, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [step, setStep] = useState<'form' | 'success'>('form');
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
// Sub-modals state
const [showSchemeModal, setShowSchemeModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [formData, setFormData] = useState({
name: '',
client: '',
startDate: '',
endDate: '',
technician: '',
environment: '',
weightKg: '', // Text input for number
});
React.useEffect(() => {
if (isOpen) {
setStep('form');
setCreatedProjectId(null);
if (initialData) {
setFormData({
name: initialData.name || '',
client: initialData.client || '',
startDate: initialData.startDate ? new Date(initialData.startDate).toISOString().split('T')[0] : '',
endDate: initialData.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : '',
technician: initialData.technician || '',
environment: initialData.environment || '',
weightKg: initialData.weightKg ? String(initialData.weightKg) : ''
});
} else {
setFormData({ name: '', client: '', startDate: '', endDate: '', technician: '', environment: '', weightKg: '' });
}
}
}, [initialData, isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log('Submit button clicked. Current formData:', formData);
if (isGuest()) {
console.warn('Submission blocked: user is guest');
showGuestWarning();
return;
}
if (!formData.name || !formData.client) {
console.warn('Submission blocked: required fields missing', { name: !!formData.name, client: !!formData.client });
alert('Por favor, preencha o Nome do Projeto e o Cliente.');
return;
}
setLoading(true);
try {
const payload = {
...formData,
weightKg: formData.weightKg ? parseFloat(formData.weightKg) : null
};
console.log('Sending project payload to backend...', payload);
if (initialData) {
console.log('Updating project:', initialData.id);
const res = await api.put(`/projects/${initialData.id}`, payload);
console.log('Project updated successfully:', res.data);
onSuccess();
onClose();
} else {
console.log('Posting new project...');
const res = await api.post('/projects', payload);
console.log('Project created response:', res.data);
const pid = res.data.id || res.data._id;
if (!pid) {
console.error('No ID returned from create project:', res.data);
throw new Error('ID do projeto não retornado pelo servidor.');
}
setCreatedProjectId(pid);
onSuccess();
setStep('success');
}
} catch (error: unknown) {
console.error('Error saving project:', error);
const axiosError = error as any;
const errorMsg = axiosError.response?.data?.error || axiosError.message || 'Erro desconhecido';
alert(`Erro ao salvar projeto: ${errorMsg}`);
} finally {
setLoading(false);
}
};
const handleClose = () => {
// If in success step, we just finish
onClose();
};
return (
<>
<Modal isOpen={isOpen} onClose={handleClose} title={step === 'form' ? (initialData ? "Editar Projeto" : "Novo Projeto") : "Projeto Criado com Sucesso!"}>
{step === 'form' ? (
<form onSubmit={handleSubmit} className="space-y-4">
<Input name="name" label="Nome do Projeto" required value={formData.name} onChange={handleChange} />
<Input name="client" label="Cliente" required value={formData.client} onChange={handleChange} />
<Input name="technician" label="Responsável Técnico" value={formData.technician} onChange={handleChange} />
<div className="grid grid-cols-2 gap-4">
<Input name="startDate" label="Início Planejado" type="date" value={formData.startDate} onChange={handleChange} />
<Input name="endDate" label="Fim Planejado" type="date" value={formData.endDate} onChange={handleChange} />
</div>
<Input name="weightKg" label="Peso Total (Kg)" type="number" placeholder="0.00" value={formData.weightKg} onChange={handleChange} />
<Select
name="environment"
label="Ambiente (Corrosividade)"
options={[
{ label: 'C1 - Muito Baixa', value: 'C1' },
{ label: 'C2 - Baixa', value: 'C2' },
{ label: 'C3 - Média', value: 'C3' },
{ label: 'C4 - Alta', value: 'C4' },
{ label: 'C5 - Muito Alta', value: 'C5' },
{ label: 'CX - Extrema', value: 'CX' }
]}
value={formData.environment}
onChange={handleChange}
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={handleClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Criar Projeto')}</Button>
</div>
</form>
) : (
<div className="space-y-6 text-center py-4">
<div className="space-y-2">
<h3 className="text-xl font-bold text-text-main">Configurar Esquema de Pintura?</h3>
<p className="text-sm text-text-muted px-4">O projeto foi criado. Deseja adicionar um esquema de pintura agora?</p>
</div>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setShowImportModal(true)}
className="flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all group"
>
<div className="p-3 rounded-full bg-surface-soft group-hover:bg-white text-primary transition-colors">
<FileDown size={24} />
</div>
<span className="font-bold text-sm text-text-main group-hover:text-primary">Importar de Obra</span>
</button>
<button
onClick={() => setShowSchemeModal(true)}
className="flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all group"
>
<div className="p-3 rounded-full bg-surface-soft group-hover:bg-white text-primary transition-colors">
<Plus size={24} />
</div>
<span className="font-bold text-sm text-text-main group-hover:text-primary">Criar Novo</span>
</button>
</div>
<div className="pt-4 border-t border-border/40">
<Button variant="ghost" className="w-full" onClick={handleClose}>Pular / Finalizar</Button>
</div>
</div>
)}
</Modal>
{createdProjectId && (
<>
<CreatePaintingSchemeModal
isOpen={showSchemeModal}
onClose={() => setShowSchemeModal(false)}
onSuccess={() => { setShowSchemeModal(false); onClose(); }}
projectId={createdProjectId}
/>
<ImportSchemeModal
isOpen={showImportModal}
onClose={() => setShowImportModal(false)}
onSuccess={() => { setShowImportModal(false); onClose(); }}
targetProjectId={createdProjectId}
/>
</>
)}
</>
);
};

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal } from '../Modal';
import { Button } from '../Button';
import { Edit, Trash2, Plus } from 'lucide-react';
import { DiluentRegistrationModal } from './DiluentRegistrationModal';
import { getDataSheets, deleteDataSheet } from '../../services/dataSheetService';
import type { TechnicalDataSheet } from '../../types';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
interface DiluentListModalProps {
isOpen: boolean;
onClose: () => void;
}
export const DiluentListModal: React.FC<DiluentListModalProps> = ({ isOpen, onClose }) => {
const { isAdmin } = useAuth();
const { showToast } = useToast();
const [diluents, setDiluents] = useState<TechnicalDataSheet[]>([]);
const [loading, setLoading] = useState(false);
// State for the registration/edit modal
const [showFormModal, setShowFormModal] = useState(false);
const [selectedDiluent, setSelectedDiluent] = useState<TechnicalDataSheet | undefined>(undefined);
const fetchDiluents = useCallback(async () => {
setLoading(true);
try {
const response = await getDataSheets();
// Filter only THINNER types
const filtered = response.data.filter(ds =>
ds.type === 'THINNER' || ds.type === 'DILUENTE'
);
setDiluents(filtered);
} catch (error) {
console.error('Error fetching diluents:', error);
showToast('Erro ao carregar lista de diluentes.', 'error');
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
if (isOpen) {
fetchDiluents();
}
}, [isOpen, fetchDiluents]);
const handleDelete = async (id: string, name: string) => {
if (confirm(`Tem certeza que deseja excluir o diluente "${name}" ? `)) {
try {
await deleteDataSheet(id);
showToast('Diluente excluído com sucesso.', 'success');
fetchDiluents();
} catch (error) {
console.error('Error deleting diluent:', error);
showToast('Erro ao excluir diluente.', 'error');
}
}
};
const handleEdit = (diluent: TechnicalDataSheet) => {
setSelectedDiluent(diluent);
setShowFormModal(true);
};
const handleNew = () => {
setSelectedDiluent(undefined);
setShowFormModal(true);
};
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title="Gerenciar Diluentes"
maxWidth="max-w-4xl"
>
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-text-muted text-sm">
Lista de diluentes cadastrados no sistema.
</p>
{isAdmin() && (
<Button onClick={handleNew} className="flex items-center gap-2">
<Plus size={16} />
Novo Diluente
</Button>
)}
</div>
<div className="bg-surface rounded-xl border border-border/40 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-text-muted">Carregando...</div>
) : diluents.length === 0 ? (
<div className="p-8 text-center text-text-muted">Nenhum diluente encontrado.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-soft border-b border-border/40">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Nome</th>
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Fabricante</th>
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Estoque Mín.</th>
<th className="px-6 py-3 text-right text-xs font-bold text-text-muted uppercase">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{diluents.map((diluent) => (
<tr key={diluent._id || diluent.id} className="hover:bg-surface-hover transition-colors">
<td className="px-6 py-3 text-sm font-medium text-text-main">
{diluent.name}
</td>
<td className="px-6 py-3 text-sm text-text-secondary">
{diluent.manufacturer}
{diluent.manufacturerCode && (
<span className="text-xs text-text-muted block">
{diluent.manufacturerCode}
</span>
)}
</td>
<td className="px-6 py-3 text-sm text-text-secondary">
{diluent.minStock ? `${diluent.minStock} L` : '-'}
</td>
<td className="px-6 py-3 text-right flex justify-end gap-2">
{isAdmin() && (
<>
<button
onClick={() => handleEdit(diluent)}
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Editar"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDelete(diluent._id || diluent.id, diluent.name)}
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
title="Excluir"
>
<Trash2 size={16} />
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="flex justify-end pt-4">
<Button variant="ghost" onClick={onClose}>
Fechar
</Button>
</div>
</div>
</Modal>
{showFormModal && (
<DiluentRegistrationModal
isOpen={showFormModal}
onClose={() => setShowFormModal(false)}
onSuccess={() => {
fetchDiluents();
setShowFormModal(false);
}}
initialData={selectedDiluent}
/>
)}
</>
);
};

View File

@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { TechnicalDataSheet } from '../../types';
import { createDataSheet, updateDataSheet } from '../../services/dataSheetService';
interface DiluentRegistrationModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: TechnicalDataSheet;
}
export const DiluentRegistrationModal: React.FC<DiluentRegistrationModalProps> = ({ isOpen, onClose, onSuccess, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
// Form Data
const [name, setName] = useState('');
const [manufacturer, setManufacturer] = useState('');
const [manufacturerCode, setManufacturerCode] = useState('');
const [minStock, setMinStock] = useState('');
const [typicalApplication, setTypicalApplication] = useState('');
useEffect(() => {
if (isOpen) {
if (initialData) {
setName(initialData.name);
setManufacturer(initialData.manufacturer || '');
setManufacturerCode(initialData.manufacturerCode || '');
setMinStock(String(initialData.minStock || ''));
setTypicalApplication(initialData.typicalApplication || '');
} else {
setName('');
setManufacturer('');
setManufacturerCode('');
setMinStock('');
setTypicalApplication('');
}
}
}, [isOpen, initialData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
setLoading(true);
const formData = new FormData();
formData.append('name', name);
formData.append('manufacturer', manufacturer);
formData.append('manufacturerCode', manufacturerCode);
formData.append('minStock', String(Number(minStock) || 0));
formData.append('typicalApplication', typicalApplication);
formData.append('type', 'THINNER');
// Ensure fileUrl is handled if required by backend, existing logic used a placeholder string
if (!initialData) {
formData.append('fileUrl', 'placeholder_url');
}
try {
if (initialData && (initialData._id || initialData.id)) {
await updateDataSheet(initialData._id || initialData.id, formData);
} else {
await createDataSheet(formData);
}
onSuccess();
onClose();
} catch (error: any) {
console.error('Error saving diluent:', error);
alert(error.response?.data?.error || 'Erro ao salvar diluente.');
} finally {
setLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialData ? "Editar Diluente" : "Cadastrar Novo Diluente"}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Nome do Diluente"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="Ex: Diluente Epóxi 123"
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fabricante"
name="manufacturer"
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
required
placeholder="Ex: Sherwin Williams"
/>
<Input
label="Cód. Fabricante"
name="manufacturerCode"
value={manufacturerCode}
onChange={(e) => setManufacturerCode(e.target.value)}
placeholder="Ex: REF-001"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Estoque Mínimo (L)"
name="minStock"
type="number"
value={minStock}
onChange={(e) => setMinStock(e.target.value)}
placeholder="0"
/>
</div>
<Input
label="Aplicação Típica"
name="typicalApplication"
value={typicalApplication}
onChange={(e) => setTypicalApplication(e.target.value)}
placeholder="Ex: Diluição de tintas epóxi série X"
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react';
import { Modal } from '../Modal';
import { Select } from '../Select';
import { Button } from '../Button';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Project, PaintingScheme } from '../../types';
interface ImportSchemeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
targetProjectId: string;
isExchangeMode?: boolean;
hasInspections?: boolean;
}
export const ImportSchemeModal: React.FC<ImportSchemeModalProps> = ({ isOpen, onClose, onSuccess, targetProjectId, isExchangeMode, hasInspections }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [schemes, setSchemes] = useState<PaintingScheme[]>([]);
const [sourceProjectId, setSourceProjectId] = useState('');
const [sourceSchemeId, setSourceSchemeId] = useState('');
const [shouldReplace, setShouldReplace] = useState(false);
// Initial state setup
useEffect(() => {
if (isOpen) {
// Default replace to TRUE if in exchange mode and allowed (no inspections)
if (isExchangeMode && !hasInspections) {
setShouldReplace(true);
} else {
setShouldReplace(false);
}
api.get('/projects').then(res => {
const otherProjects = res.data.filter((p: Project) => p.id !== targetProjectId);
setProjects(otherProjects);
}).catch(err => console.error("Error loading projects", err));
} else {
setSourceProjectId('');
setSourceSchemeId('');
setSchemes([]);
setShouldReplace(false);
}
}, [isOpen, targetProjectId, isExchangeMode, hasInspections]);
// Fetch schemes when project selected
useEffect(() => {
if (sourceProjectId) {
api.get(`/painting-schemes?projectId=${sourceProjectId}`).then(res => {
const projectSchemes = res.data.filter((s: PaintingScheme) => s.projectId === sourceProjectId);
setSchemes(projectSchemes);
}).catch(err => console.error("Error loading schemes", err));
} else {
setSchemes([]);
}
}, [sourceProjectId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
if (!sourceSchemeId) return;
if (!targetProjectId) {
alert("Erro: Projeto de destino não identificado. Tente recarregar a página.");
return;
}
setLoading(true);
try {
// If Replacing, first delete ALL existing schemes for this project
if (shouldReplace && isExchangeMode && !hasInspections) {
// 1. Fetch current schemes
const currentSchemesRes = await api.get(`/painting-schemes?projectId=${targetProjectId}`);
const currentSchemes = currentSchemesRes.data.filter((s: PaintingScheme) => s.projectId === targetProjectId);
// 2. Delete them
await Promise.all(currentSchemes.map((s: PaintingScheme) => api.delete(`/painting-schemes/${s.id}`)));
}
const schemeToClone = schemes.find(s => s.id === sourceSchemeId);
if (!schemeToClone) throw new Error("Scheme not found");
// Clone and remove ID/Project specific fields to create a fresh copy
const schemeData = { ...(schemeToClone as any) };
delete schemeData.id;
delete schemeData.projectId;
delete schemeData._id;
delete schemeData.__v;
delete schemeData.createdAt;
delete schemeData.updatedAt;
await api.post('/painting-schemes', {
...schemeData,
projectId: targetProjectId,
// If replacing, keep original name? User asked to "Exchange". Maybe we don't need "(Cópia)" suffix if strictly exchanging.
// But safer to keep distinct unless user renames. Let's keep existing logic or maybe drop suffix if replacing?
// Step 1251 prompt implies "Troca Limpa". A clean swap usually implies taking the new scheme AS IS.
name: shouldReplace ? schemeData.name : `${schemeData.name} (Cópia)`
});
onSuccess();
onClose();
} catch (error) {
console.error('Error importing scheme', error);
alert('Erro ao importar esquema');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={isExchangeMode ? "Trocar / Importar Esquema" : "Importar Esquema de Pintura"}>
<form onSubmit={handleSubmit} className="space-y-6">
<p className="text-sm text-text-muted">Selecione uma obra existente para copiar seu esquema de pintura.</p>
{isExchangeMode && hasInspections && (
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 items-start">
<div className="mt-1 text-amber-600 font-bold text-xs uppercase">Atenção</div>
<div className="text-xs text-amber-700">
Esta obra possui inspeções ou registros cadastrados. Por segurança, <strong>não é permitido substituir</strong> o esquema atual, apenas adicionar novos itens.
</div>
</div>
)}
{isExchangeMode && !hasInspections && (
<div className="bg-surface-soft p-3 rounded-lg border border-border flex items-center gap-3">
<input
type="checkbox"
id="replace-check"
className="w-4 h-4 text-primary rounded border focus:ring-primary"
checked={shouldReplace}
onChange={(e) => setShouldReplace(e.target.checked)}
/>
<label htmlFor="replace-check" className="text-sm font-medium text-text-main cursor-pointer select-none">
Substituir todos os esquemas atuais (Limpar Obra)
</label>
</div>
)}
<Select
name="sourceProject"
label="Obra/Projeto de Origem"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={sourceProjectId}
onChange={(e) => setSourceProjectId(e.target.value)}
required
/>
<Select
name="sourceScheme"
label="Esquema de Pintura"
options={schemes.map(s => ({ label: s.name, value: s.id }))}
value={sourceSchemeId}
onChange={(e) => setSourceSchemeId(e.target.value)}
required
disabled={!sourceProjectId}
/>
{schemes.length === 0 && sourceProjectId && (
<p className="text-xs text-amber-500 font-bold">Esta obra não possui esquemas cadastrados.</p>
)}
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading || !sourceSchemeId}>
{loading ? 'Processando...' : (shouldReplace ? 'Trocar Esquema' : 'Adicionar Cópia')}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { stockService, type StockMovement, type StockItem } from '../../services/stockService';
import { format } from 'date-fns';
import { ArrowUp, ArrowDown, RefreshCw, Trash2, Edit2, Save, X, FileText, Activity } from 'lucide-react';
import { useAuth } from '../../context/useAuth';
interface StockHistoryModalProps {
isOpen: boolean;
onClose: () => void;
item: StockItem;
onUpdate?: () => void;
}
interface AuditLog {
_id: string;
action: 'CREATE' | 'UPDATE' | 'DELETE';
userName: string;
details: string;
timestamp: string;
movementNumber?: number;
}
export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({ isOpen, onClose, item, onUpdate }) => {
const { isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState<'movements' | 'logs'>('movements');
const [movements, setMovements] = useState<StockMovement[]>([]);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [currentItem, setCurrentItem] = useState<StockItem>(item);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [editValues, setEditValues] = useState<{ date: string; quantity: string; notes: string }>({
date: '',
quantity: '',
notes: ''
});
const fetchData = async () => {
if (item._id) {
setLoading(true);
try {
// Always fetch item to keep balance fresh
const itemData = await stockService.getById(item._id);
setCurrentItem(itemData);
if (activeTab === 'movements') {
const data = await stockService.getMovements(item._id);
setMovements(data);
} else {
const data = await stockService.getAuditLogs(item._id);
setLogs(data);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen, item, activeTab]);
const formatMovementId = (num?: number) => {
if (!num) return '-';
return `${item.rrNumber}/${String(num).padStart(2, '0')}`;
};
const filteredLogs = logs.filter(log => {
const term = searchTerm.toLowerCase();
const formattedId = formatMovementId(log.movementNumber);
return (
log.details.toLowerCase().includes(term) ||
log.userName.toLowerCase().includes(term) ||
formattedId.toLowerCase().includes(term)
);
});
const handleEditClick = (move: StockMovement) => {
setEditingId(move._id!);
const dateStr = new Date(move.date).toISOString().slice(0, 16);
setEditValues({
date: dateStr,
quantity: String(move.quantity),
notes: move.notes || ''
});
};
const handleCancelEdit = () => {
setEditingId(null);
setEditValues({ date: '', quantity: '', notes: '' });
};
const handleSave = async (id: string) => {
try {
await stockService.updateMovement(id, {
date: new Date(editValues.date).toISOString(),
quantity: Number(editValues.quantity),
notes: editValues.notes
});
setEditingId(null);
fetchData();
if (onUpdate) onUpdate();
} catch (error) {
console.error('Error updating movement:', error);
alert('Erro ao atualizar movimentação.');
}
};
const handleDelete = async (id: string, qty: number) => {
if (confirm(`Tem certeza que deseja excluir esta movimentação de ${qty}? O saldo do lote será revertido.`)) {
try {
await stockService.deleteMovement(id);
fetchData();
if (onUpdate) onUpdate();
} catch (error) {
console.error('Error deleting movement:', error);
alert('Erro ao excluir movimentação.');
}
}
};
const getMovementIcon = (type: string) => {
switch (type) {
case 'ENTRY': return <ArrowUp size={16} className="text-green-500" />;
case 'CONSUMPTION': return <ArrowDown size={16} className="text-blue-500" />;
case 'ADJUSTMENT': return <RefreshCw size={16} className="text-amber-500" />;
default: return null;
}
};
const getMovementLabel = (type: string) => {
switch (type) {
case 'ENTRY': return 'Entrada';
case 'CONSUMPTION': return 'Consumo';
case 'ADJUSTMENT': return 'Ajuste';
default: return type;
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Histórico - ${item.rrNumber}`}
maxWidth="max-w-4xl"
>
<div className="space-y-4">
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mb-4 flex justify-between items-center">
<div>
<p className="text-sm text-text-secondary">Produto: <span className="text-text-main font-semibold">{typeof currentItem.dataSheetId === 'object' ? currentItem.dataSheetId.name : '...'}</span></p>
<p className="text-sm text-text-secondary">Lote: <span className="text-text-main font-semibold">{currentItem.batchNumber}</span></p>
</div>
<div className="text-right">
<p className="text-sm text-text-secondary">Saldo Atual</p>
<span className="text-text-main font-bold text-2xl">{currentItem.quantity} <span className="text-lg font-normal text-text-muted">{currentItem.unit}</span></span>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-border/40 mb-4 justify-between items-center">
<div className="flex">
<button
onClick={() => setActiveTab('movements')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'movements' ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text-main'}`}
>
<Activity size={16} />
Movimentações
</button>
<button
onClick={() => setActiveTab('logs')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'logs' ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text-main'}`}
>
<FileText size={16} />
Logs de Auditoria
</button>
</div>
{activeTab === 'logs' && (
<input
type="text"
placeholder="Buscar por nº Mov ou Detalhes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="text-xs bg-surface border border-border/40 rounded-lg px-3 py-1.5 focus:outline-none focus:border-primary w-64 text-text-main placeholder-text-muted"
/>
)}
</div>
{loading ? (
<div className="text-center py-8 text-text-muted">Carregando...</div>
) : activeTab === 'movements' ? (
// MOVEMENTS TABLE
movements.length === 0 ? (
<div className="text-center py-8 text-text-muted">Nenhuma movimentação registrada.</div>
) : (
<div className="relative overflow-hidden rounded-xl border border-border/40 bg-surface">
<table className="w-full text-sm text-left">
<thead className="bg-surface-soft text-text-muted font-medium uppercase text-xs">
<tr>
<th className="px-4 py-3 w-32 text-center">ID</th>
<th className="px-4 py-3 w-40">Data</th>
<th className="px-4 py-3 w-32">Tipo</th>
<th className="px-4 py-3 w-28">Qtd</th>
<th className="px-4 py-3 w-40">Responsável</th>
<th className="px-4 py-3">Detalhes</th>
{isAdmin() && <th className="px-4 py-3 w-24 text-right">Ações</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{movements.map((move: any) => {
const isEditing = editingId === move._id;
return (
<tr key={move._id} className="hover:bg-surface-hover/50">
<td className="px-4 py-3 text-center font-mono text-text-muted text-xs">
{formatMovementId(move.movementNumber)}
</td>
<td className="px-4 py-3 text-text-main align-top">
{isEditing ? (
<input
type="datetime-local"
value={editValues.date}
onChange={(e) => setEditValues({ ...editValues, date: e.target.value })}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs"
/>
) : (
<span className="whitespace-nowrap">
{format(new Date(move.date), 'dd/MM/yyyy HH:mm')}
</span>
)}
</td>
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 font-medium">
{getMovementIcon(move.type)}
<span>{getMovementLabel(move.type)}</span>
</div>
</td>
<td className="px-4 py-3 font-bold align-top">
{isEditing ? (
<input
type="number"
value={editValues.quantity}
onChange={(e) => setEditValues({ ...editValues, quantity: e.target.value })}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs"
placeholder="(ex: -10)"
/>
) : (
<span className={move.quantity > 0 ? 'text-green-500' : 'text-red-500'}>
{move.quantity > 0 ? '+' : ''}{move.quantity}
</span>
)}
</td>
<td className="px-4 py-3 text-text-secondary align-top text-xs">
<div className="line-clamp-2" title={move.responsible}>
{move.responsible}
</div>
</td>
<td className="px-4 py-3 text-text-muted text-xs align-top">
{isEditing ? (
<textarea
value={editValues.notes}
onChange={(e) => setEditValues({ ...editValues, notes: e.target.value })}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-y min-h-[2.5rem]"
placeholder="Notas..."
/>
) : (
<div className="line-clamp-2">
{move.type === 'ADJUSTMENT' && move.reason}
{move.type === 'CONSUMPTION' && `Solicitante: ${move.requester}`}
{move.notes && ` - ${move.notes}`}
{!move.notes && !move.reason && !move.requester && '-'}
</div>
)}
</td>
{isAdmin() && (
<td className="px-4 py-3 text-right align-top">
{isEditing ? (
<div className="flex justify-end gap-2">
<button
onClick={() => handleSave(move._id!)}
className="p-1.5 bg-green-500/10 text-green-500 hover:bg-green-500/20 rounded-lg transition-colors"
title="Salvar"
>
<Save size={16} />
</button>
<button
onClick={handleCancelEdit}
className="p-1.5 bg-red-500/10 text-red-500 hover:bg-red-500/20 rounded-lg transition-colors"
title="Cancelar"
>
<X size={16} />
</button>
</div>
) : (
<div className="flex justify-end gap-2">
<button
onClick={() => handleEditClick(move)}
className="p-1.5 text-blue-500 hover:bg-blue-500/10 rounded-lg transition-colors"
title="Editar"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(move._id!, move.quantity)}
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
title="Excluir"
>
<Trash2 size={16} />
</button>
</div>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)
) : (
// LOGS TABLE
filteredLogs.length === 0 ? (
<div className="text-center py-8 text-text-muted">Nenhum log de auditoria encontrado.</div>
) : (
<div className="relative overflow-hidden rounded-xl border border-border/40 bg-surface">
<table className="w-full text-sm text-left">
<thead className="bg-surface-soft text-text-muted font-medium uppercase text-xs">
<tr>
<th className="px-4 py-3 w-40">Data</th>
<th className="px-4 py-3 w-32">Ação</th>
<th className="px-4 py-3 w-32 text-center">ID Mov.</th>
<th className="px-4 py-3 w-40">Usuário</th>
<th className="px-4 py-3">Detalhes da Alteração</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{filteredLogs.map((log) => (
<tr key={log._id} className="hover:bg-surface-hover/50">
<td className="px-4 py-3 text-text-main align-top whitespace-nowrap">
{format(new Date(log.timestamp), 'dd/MM/yyyy HH:mm')}
</td>
<td className="px-4 py-3 align-top font-bold">
<span className={
log.action === 'CREATE' ? 'text-green-500' :
log.action === 'UPDATE' ? 'text-blue-500' :
'text-red-500'
}>
{log.action === 'CREATE' ? 'CRIAÇÃO' :
log.action === 'UPDATE' ? 'EDIÇÃO' : 'EXCLUSÃO'}
</span>
</td>
<td className="px-4 py-3 text-center align-top font-mono text-text-main">
{formatMovementId(log.movementNumber)}
</td>
<td className="px-4 py-3 text-text-secondary align-top">
{log.userName}
</td>
<td className="px-4 py-3 text-text-muted text-xs align-top font-mono">
{log.details}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,268 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { stockService, type StockItem } from '../../services/stockService';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
interface StockModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: StockItem;
initialType?: 'PAINT' | 'THINNER';
}
export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSuccess, initialData, initialType = 'PAINT' }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [dataSheets, setDataSheets] = useState<any[]>([]);
// Form Data
const [dataSheetId, setDataSheetId] = useState('');
const [rrNumber, setRrNumber] = useState('');
const [batchNumber, setBatchNumber] = useState('');
const [color, setColor] = useState('');
const [invoiceNumber, setInvoiceNumber] = useState('');
const [receivedBy, setReceivedBy] = useState('');
const [quantity, setQuantity] = useState('');
const [unit, setUnit] = useState('L');
const [expirationDate, setExpirationDate] = useState('');
const [minStock, setMinStock] = useState('');
const [notes, setNotes] = useState('');
useEffect(() => {
const fetchDataSheets = async () => {
try {
const res = await api.get('/datasheets'); // Assuming this endpoint exists and lists all
setDataSheets(res.data);
} catch (err) {
console.error("Error fetching datasheets", err);
}
};
if (isOpen) {
fetchDataSheets();
if (initialData) {
setDataSheetId(typeof initialData.dataSheetId === 'object' ? initialData.dataSheetId._id : initialData.dataSheetId);
setRrNumber(initialData.rrNumber);
setBatchNumber(initialData.batchNumber);
setColor(initialData.color || '');
setInvoiceNumber(initialData.invoiceNumber || '');
setReceivedBy(initialData.receivedBy || '');
setQuantity(String(initialData.quantity));
setUnit(initialData.unit);
setExpirationDate(initialData.expirationDate ? new Date(initialData.expirationDate).toISOString().split('T')[0] : '');
setMinStock(String(initialData.minStock || 0));
setNotes(initialData.notes || '');
} else {
// Reset form
setDataSheetId('');
setRrNumber('');
setBatchNumber('');
setColor('');
setInvoiceNumber('');
setReceivedBy('');
setQuantity('');
setUnit('L');
setExpirationDate('');
setMinStock('0');
setNotes('');
}
}
}, [isOpen, initialData]);
// Handle filling color etc if picking a DataSheet (Optional feature, not implemented yet)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
setLoading(true);
const payload: any = {
dataSheetId,
rrNumber,
batchNumber,
color,
invoiceNumber,
receivedBy,
unit,
expirationDate: expirationDate || undefined,
minStock: Number(minStock) || 0,
notes
};
// If creating, send quantity. If updating, DO NOT send quantity (handled via adjusts)
if (!initialData) {
payload.quantity = Number(quantity);
}
try {
if (initialData) {
await stockService.update(initialData._id!, payload);
} else {
await stockService.create(payload);
}
onSuccess();
} catch (error: any) {
console.error('Error saving stock item:', error);
alert(error.response?.data?.error || 'Erro ao salvar item.');
} finally {
setLoading(false);
}
};
const isThinner = initialData
? (typeof initialData.dataSheetId === 'object' && (initialData.dataSheetId.type === 'THINNER' || initialData.dataSheetId.type === 'DILUENTE'))
: (initialType === 'THINNER');
const filteredDataSheets = dataSheets.filter(ds => {
const dsType = ds.type || 'PAINT';
const isDsThinner = dsType === 'THINNER' || dsType === 'DILUENTE';
return isThinner ? isDsThinner : !isDsThinner;
});
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialData ? "Editar Detalhes do Lote" : `Nova Entrada de Estoque (${isThinner ? 'Diluente' : 'Tinta'})`}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Select
label="Produto (Ficha Técnica)"
name="dataSheetId"
value={dataSheetId}
onChange={(e) => {
const val = e.target.value;
setDataSheetId(val);
// Auto-fill minStock from DataSheet if set and current is empty/0
const ds = dataSheets.find(d => d._id === val);
if (ds && ds.minStock && (!minStock || minStock === '0')) {
setMinStock(String(ds.minStock));
}
}}
options={filteredDataSheets.map(ds => ({ label: `${ds.name} - ${ds.manufacturer}`, value: ds._id }))}
disabled={!!initialData} // Lock product on edit
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="RR (Rastreabilidade)"
name="rrNumber"
value={rrNumber}
onChange={(e) => setRrNumber(e.target.value)}
required
disabled={!!initialData} // Usually unique ID shouldn't change easily
/>
<Input
label="Lote Fabricante"
name="batchNumber"
value={batchNumber}
onChange={(e) => setBatchNumber(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Nota Fiscal"
name="invoiceNumber"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
/>
<Input
label="Recebido Por"
name="receivedBy"
value={receivedBy}
onChange={(e) => setReceivedBy(e.target.value)}
/>
</div>
{!isThinner && (
<Input
label="Cor"
name="color"
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="Ex: Amarelo Segurança, CINZA N6.5"
/>
)}
{!initialData && (
<div className="grid grid-cols-2 gap-4">
<Input
label="Quantidade Inicial"
name="quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
required
/>
<Select
label="Unidade"
name="unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
options={[
{ label: 'Litros (L)', value: 'L' },
{ label: 'Galões (Gal)', value: 'Gal' },
{ label: 'Quartos (Qt)', value: 'Qt' },
{ label: 'Kg', value: 'Kg' },
{ label: 'Unidade (Un)', value: 'Un' }
]}
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{!isThinner && (
<Input
label="Data de Validade"
name="expirationDate"
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
/>
)}
<div className={isThinner ? "col-span-2" : ""}>
<Input
label="Estoque Mínimo (L)"
name="minStock"
type="number"
value={minStock}
onChange={(e) => setMinStock(e.target.value)}
placeholder="Qtd de alerta"
/>
</div>
</div>
<Input
label="Observações"
name="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</Modal >
);
};

View File

@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { stockService, type StockItem } from '../../services/stockService';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
interface StockOutModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
item: StockItem;
}
export const StockOutModal: React.FC<StockOutModalProps> = ({ isOpen, onClose, onSuccess, item }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [type, setType] = useState<'CONSUMPTION' | 'ADJUSTMENT'>('CONSUMPTION');
const [quantity, setQuantity] = useState('');
// Adjustment fields
const [reason, setReason] = useState('');
// Consumption fields
const [requester, setRequester] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
const qtyNum = Number(quantity);
if (!qtyNum || qtyNum <= 0) {
alert('Quantidade deve ser maior que zero.');
return;
}
if (type === 'ADJUSTMENT' && !reason) {
alert('Motivo é obrigatório para ajustes.');
return;
}
if (type === 'CONSUMPTION' && !requester) {
alert('Solicitante é obrigatório para consumo.');
return;
}
setLoading(true);
try {
if (type === 'ADJUSTMENT') {
// Adjust can be positive or negative, but here we frame it as "Stock Out" mostly?
// Actually the requirement is "Two systems of Stock Out". So Adjustment implies REMOVING?
// Or "Correction" which could be adding?
// Let's assume this modal is generic, but usually used for outs.
// However, "Baixa por ajuste técnico" implies reducing.
// But typically adjustment allows both. Let's send negative quantity for reducing.
await stockService.adjust(item._id!, {
quantityDelta: -qtyNum, // Negative for removal
reason
});
} else {
await stockService.consume(item._id!, {
quantityConsumed: qtyNum,
requester,
date
});
}
onSuccess();
} catch (error: any) {
console.error('Error processing stock out:', error);
alert(error.response?.data?.error || 'Erro ao realizar baixa.');
} finally {
setLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Realizar Baixa - ${item.rrNumber}`}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mb-4">
<p className="text-sm font-semibold text-text-muted">Item Selecionado:</p>
<p className="text-lg font-bold text-text-main">
{typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Produto'}
</p>
<p className="text-sm text-text-secondary">Lote: {item.batchNumber} | Cor: {item.color || '-'}</p>
<p className="text-sm text-text-secondary mt-1">
Disponível: <span className="font-bold text-green-500">{item.quantity} {item.unit}</span>
</p>
</div>
<Select
label="Tipo de Baixa"
name="type"
value={type}
onChange={(e) => setType(e.target.value as any)}
options={[
{ label: 'Consumo em Obra', value: 'CONSUMPTION' },
{ label: 'Ajuste Técnico / Perda', value: 'ADJUSTMENT' }
]}
/>
<Input
label="Quantidade a Baixar"
name="quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
required
placeholder={`Max: ${item.quantity}`}
/>
{type === 'ADJUSTMENT' ? (
<Input
label="Motivo do Ajuste"
name="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex: Material vencido, Pote danificado, Desperdício teste..."
required
/>
) : (
<>
<Input
label="Solicitante (Encarregado/Pintor)"
name="requester"
value={requester}
onChange={(e) => setRequester(e.target.value)}
required
/>
<Input
label="Data do Consumo"
name="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</>
)}
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancelar
</Button>
<Button type="submit" variant="danger" disabled={loading}>
{loading ? 'Processando...' : 'Confirmar Baixa'}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,186 @@
import React, { useRef, useLayoutEffect } from 'react';
import { format, isValid } from 'date-fns';
import type { Project } from '../../types';
import '../../styles/reports.css';
interface AnalyticalReportProps {
project: Project;
logoUrl?: string;
}
const ProgressFill: React.FC<{ progress: number }> = ({ progress }) => {
const fillRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (fillRef.current) {
fillRef.current.style.setProperty('--progress', `${progress}%`);
}
}, [progress]);
return <div ref={fillRef} className="evol-fill" />;
};
export const AnalyticalReport: React.FC<AnalyticalReportProps> = ({ project, logoUrl }) => {
const inspections = project.inspections || [];
const sumWeight = inspections.reduce((acc, curr) => acc + (curr.weightKg || 0), 0);
const totalWeight = project.weightKg || 0;
const progress = totalWeight > 0 ? Math.min(Math.round((sumWeight / totalWeight) * 100), 100) : 0;
// Período
const startDate = project.startDate ? new Date(project.startDate) : null;
const endDate = project.endDate ? new Date(project.endDate) : null;
const periodStr = (startDate && isValid(startDate) && endDate && isValid(endDate))
? `${format(startDate, 'MM/yyyy')} ${format(endDate, 'MM/yyyy')}`
: '--/----';
return (
<div className="report-container print:block hidden" id="analytical-report">
<header className="report-header">
<div className="brand">
{logoUrl ? (
<img src={logoUrl} alt="Logo" className="brand-logo" />
) : (
<div className="logo-placeholder"></div>
)}
</div>
<div className="text-center">
<div className="brand-title">RELATÓRIO ANALÍTICO DE OBRA</div>
<div className="brand-subtitle">
Detalhamento de Inspeções, Aplicativos e Esquemas de Pintura
</div>
</div>
<div className="meta">
<div><strong>Data:</strong> {format(new Date(), 'dd/MM/yyyy')}</div>
<div><strong>Obra:</strong> {project.name.toUpperCase()}</div>
</div>
</header>
<section className="summary">
<div className="summary-item">
<div className="summary-label">Evolução Real</div>
<div className="summary-value">{progress}%</div>
<div className="evol-bar">
<ProgressFill progress={progress} />
</div>
</div>
<div className="summary-item">
<div className="summary-label">Peso Medido (kgf)</div>
<div className="summary-value">{sumWeight.toLocaleString('pt-BR')}</div>
<div className="summary-sub">de {totalWeight.toLocaleString('pt-BR')} total</div>
</div>
<div className="summary-item">
<div className="summary-label">Responsável</div>
<div className="summary-value text-11pt">{project.technician || '________________'}</div>
<div className="summary-sub">Técnico Encarregado</div>
</div>
<div className="summary-item">
<div className="summary-label">Período de Obra</div>
<div className="summary-value text-11pt">{periodStr}</div>
<div className="summary-sub">Cronograma Previsto</div>
</div>
</section>
<div className="section-title">
<h2>ESQUEMA DE PINTURA REQUERIDO</h2>
<span>Especificação técnica por demão</span>
</div>
<table className="table">
<thead>
<tr>
<th className="w-20">Etapa</th>
<th className="w-40">Produto</th>
<th className="w-20">Cor</th>
<th className="w-20">EPS (μm)</th>
</tr>
</thead>
<tbody>
{project.paintingSchemes?.map((s, idx) => (
<tr key={idx}>
<td className="font-bold uppercase">{s.coat || s.type}</td>
<td>{s.name.toUpperCase()}</td>
<td>{s.color || '--'}</td>
<td className="font-bold">{s.epsMin}-{s.epsMax} μm</td>
</tr>
))}
{(!project.paintingSchemes || project.paintingSchemes.length === 0) && (
<tr><td colSpan={4} className="text-center p-10mm text-gray-muted">Nenhum esquema definido</td></tr>
)}
</tbody>
</table>
<div className="grid-2col">
<div>
<div className="section-title">
<h2>INSPEÇÕES REALIZADAS</h2>
</div>
<table className="table">
<thead>
<tr>
<th className="w-25">Data</th>
<th className="w-40">Peça / Área</th>
<th className="w-20">Peso</th>
<th className="w-15">Status</th>
</tr>
</thead>
<tbody>
{inspections.slice(0, 15).map((insp, idx) => (
<tr key={idx}>
<td>{insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</td>
<td className="uppercase font-medium text-8pt">{insp.pieceDescription}</td>
<td className="font-bold">{insp.weightKg?.toLocaleString('pt-BR')}</td>
<td>
<span className={`badge ${insp.appearance === 'approved' ? 'badge-ok' : 'badge-err'}`}>
{insp.appearance === 'approved' ? 'OK' : 'REJ'}
</span>
</td>
</tr>
))}
{inspections.length === 0 && (
<tr><td colSpan={4} className="text-center p-5mm text-gray-muted">Sem registros</td></tr>
)}
</tbody>
</table>
</div>
<div>
<div className="section-title">
<h2>REGISTROS DE APLICAÇÃO</h2>
</div>
<table className="table">
<thead>
<tr>
<th className="w-25">Data</th>
<th className="w-35">Etapa</th>
<th className="w-20">EPS Seca</th>
<th className="w-20">Pintor</th>
</tr>
</thead>
<tbody>
{project.applicationRecords?.slice(0, 15).map((record, idx) => (
<tr key={idx}>
<td>{record.date ? format(new Date(record.date), 'dd/MM/yy') : '--'}</td>
<td className="uppercase font-medium text-8pt">{record.coatStage}</td>
<td className="font-bold">{record.dryThicknessCalc || '--'} μm</td>
<td className="uppercase text-7pt">{record.operator?.split(' ')[0]}</td>
</tr>
))}
{(!project.applicationRecords || project.applicationRecords.length === 0) && (
<tr><td colSpan={4} className="text-center p-5mm text-gray-muted">Sem registros</td></tr>
)}
</tbody>
</table>
</div>
</div>
<footer className="report-footer">
<div className="system-title">
SteelPaint - Gestão de Pintura Industrial
</div>
<div>
Gerado em {format(new Date(), 'dd/MM/yyyy')} às {format(new Date(), 'HH:mm')}h
</div>
<div className="sig-group">
<div className="sig-line">Responsável Qualidade<span></span></div>
</div>
</footer>
</div>
);
};

View File

@@ -0,0 +1,174 @@
import React, { useRef, useLayoutEffect } from 'react';
import { format, min, max, isValid } from 'date-fns';
import type { Project, Inspection } from '../../types';
import '../../styles/reports.css';
interface GeneralProjectReportProps {
projects: Project[];
inspections?: Inspection[];
title: string;
logoUrl?: string;
}
const ProgressFill: React.FC<{ progress: number }> = ({ progress }) => {
const fillRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (fillRef.current) {
fillRef.current.style.setProperty('--progress', `${progress}%`);
}
}, [progress]);
return <div ref={fillRef} className="evol-fill" />;
};
export const GeneralProjectReport: React.FC<GeneralProjectReportProps> = ({ projects, inspections = [], title, logoUrl }) => {
// Cálculos globais
const totalWeight = projects.reduce((acc, p) => acc + (p.weightKg || 0), 0);
const calculateProjectProgress = (project: Project) => {
const projectInspections = inspections.filter(i => i.projectId === project.id);
const sumWeight = projectInspections.reduce((acc, curr) => acc + (curr.weightKg || 0), 0);
const totalW = project.weightKg || 0;
return totalW > 0 ? Math.min((sumWeight / totalW) * 100, 100) : 0;
};
const avgProgress = projects.length > 0
? projects.reduce((acc, p) => acc + calculateProjectProgress(p), 0) / projects.length
: 0;
// Período (Min/Max das datas)
const allDates = projects.flatMap(p => [
p.startDate ? new Date(p.startDate) : null,
p.endDate ? new Date(p.endDate) : null
]).filter((d): d is Date => d !== null && isValid(d));
const periodStart = allDates.length > 0 ? format(min(allDates), 'MM/yyyy') : '--/----';
const periodEnd = allDates.length > 0 ? format(max(allDates), 'MM/yyyy') : '--/----';
return (
<div className="report-container print:block hidden" id="general-project-report">
<header className="report-header">
<div className="brand">
{logoUrl ? (
<img src={logoUrl} alt="Logo" className="brand-logo" />
) : (
<div className="logo-placeholder"></div>
)}
</div>
<div className="text-center">
<div className="brand-title">{title.toUpperCase()}</div>
<div className="brand-subtitle">
Obras / Projetos Situação de produção e pintura
</div>
</div>
<div className="meta">
<div><strong>Data:</strong> {format(new Date(), 'dd/MM/yyyy')}</div>
<div><strong>Responsável:</strong> ________________</div>
</div>
</header>
<section className="summary">
<div className="summary-item">
<div className="summary-label">Total de obras</div>
<div className="summary-value">{projects.length}</div>
<div className="summary-sub">Listadas neste relatório</div>
</div>
<div className="summary-item">
<div className="summary-label">Peso total (kgf)</div>
<div className="summary-value">{totalWeight.toLocaleString('pt-BR')}</div>
<div className="summary-sub">Soma dos projetos</div>
</div>
<div className="summary-item">
<div className="summary-label">Evolução média</div>
<div className="summary-value">{avgProgress.toFixed(1).replace('.', ',')}%</div>
<div className="summary-sub">Estimativa geral</div>
</div>
<div className="summary-item">
<div className="summary-label">Período</div>
<div className="summary-value">{periodStart} {periodEnd}</div>
<div className="summary-sub">Previsão de execução</div>
</div>
</section>
<div className="section-title">
<h2>OBRAS / PROJETOS</h2>
<span>Visão geral por cronograma, peso e sistema de pintura</span>
</div>
<table className="table">
<thead>
<tr>
<th className="col-obra">Obra / Projeto</th>
<th className="col-evol">Evol.</th>
<th className="col-cron">Cronograma</th>
<th className="col-peso text-center">Peso (kgf)</th>
<th className="col-tinta">Tinta</th>
<th className="col-cor">Cor</th>
</tr>
</thead>
<tbody>
{projects.map((project) => {
const progress = calculateProjectProgress(project);
const schemes = project.paintingSchemes || [];
return (
<tr key={project.id}>
<td className="col-obra">
<div className="obra-nome">{project.name.toUpperCase()}</div>
<div className="obra-cliente">Cliente: {project.client}</div>
<div className="obra-cliente">Gestor: {project.technician || '--'}</div>
</td>
<td className="col-evol">
<div className="font-bold">{Math.round(progress)}%</div>
<div className="evol-bar">
<ProgressFill progress={progress} />
</div>
</td>
<td className="col-cron">
<div className="cron">
<strong>Início:</strong> {project.startDate ? format(new Date(project.startDate), 'dd/MM/yyyy') : '--/--/----'}<br />
<strong>Término:</strong> {project.endDate ? format(new Date(project.endDate), 'dd/MM/yyyy') : '--/--/----'}
</div>
</td>
<td className="col-peso text-center">
<div className="font-bold">
{(project.weightKg || 0).toLocaleString('pt-BR')}
<span className="block text-[7pt] text-gray-400 font-normal">Est. total</span>
</div>
</td>
<td className="col-tinta">
<div className="tinta">
{schemes.length > 0 ? schemes.slice(0, 2).map((s, idx) => (
<React.Fragment key={idx}>
<strong>{s.name.toUpperCase()}</strong>
{s.coat || s.type || 'Esquema'}{idx < schemes.length - 1 && <br />}
</React.Fragment>
)) : <span className="text-gray-400 italic">Sem esquema</span>}
</div>
</td>
<td className="col-cor">
<div className="text-[8pt] leading-tight">
{schemes.length > 0 ? schemes.slice(0, 2).map((s, idx) => (
<div key={idx}>{s.color || '-'}</div>
)) : '-'}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<footer className="report-footer">
<div className="system-title">
SteelPaint - Gestão de Pintura Industrial
</div>
<div>
Gerado em {format(new Date(), 'dd/MM/yyyy')} às {format(new Date(), 'HH:mm')}h
</div>
<div className="sig-group">
<div className="sig-line">Responsável Qualidade<span></span></div>
</div>
</footer>
</div>
);
};

View File

@@ -0,0 +1,270 @@
import React from 'react';
import { format } from 'date-fns';
import type { StockItem, StockMovement } from '../../services/stockService';
import '../../styles/reports.css';
interface StockInventoryReportProps {
items: StockItem[];
movements: Map<string, StockMovement[]>; // Key: stockItemId
logoUrl?: string;
reportType?: 'PAINT' | 'THINNER';
}
export const StockInventoryReport: React.FC<StockInventoryReportProps> = ({ items, movements, logoUrl, reportType = 'PAINT' }) => {
// Agrupamento por Produto + Cor
const groups = React.useMemo(() => {
const map = new Map<string, {
key: string;
productName: string;
manufacturer: string;
color: string;
minStock: number;
totalQty: number;
unit: string;
items: StockItem[];
isLowStock: boolean;
}>();
items.forEach(item => {
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Desconhecido';
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
// Se for diluente, agrupar apenas pelo ID do produto, ignorando cor.
// Se for tinta, agrupar por produto + cor.
const key = reportType === 'THINNER'
? `${item.dataSheetId._id || item.dataSheetId}`
: `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
if (!map.has(key)) {
map.set(key, {
key,
productName,
manufacturer,
color: reportType === 'THINNER' ? '-' : (item.color || '-'),
minStock: item.minStock || 0,
totalQty: 0,
unit: item.unit,
items: [],
isLowStock: false
});
}
const group = map.get(key)!;
group.items.push(item);
group.totalQty += item.quantity;
if (item.minStock && item.minStock > group.minStock) {
group.minStock = item.minStock;
}
});
// Avaliar status de estoque baixo por grupo
for (const group of map.values()) {
if (group.minStock > 0 && group.totalQty < group.minStock) {
group.isLowStock = true;
}
}
return Array.from(map.values());
}, [items, reportType]);
// Cálculos globais
const totalItems = items.length;
const totalQuantity = items.reduce((acc, item) => acc + item.quantity, 0);
const expiredItems = items.filter(item =>
item.expirationDate && new Date(item.expirationDate) < new Date()
).length;
const lowStockGroupsCount = groups.filter(g => g.isLowStock).length;
const formatMovementType = (type: string) => {
switch (type) {
case 'ENTRY': return 'Entrada';
case 'ADJUSTMENT': return 'Ajuste';
case 'CONSUMPTION': return 'Consumo';
default: return type;
}
};
return (
<div className="report-container print:block hidden" id="stock-inventory-report">
<header className="report-header">
<div className="brand">
{logoUrl ? (
<img src={logoUrl} alt="Logo" className="brand-logo" />
) : (
<div className="logo-placeholder"></div>
)}
</div>
<div className="text-center">
<div className="brand-title">INVENTÁRIO DE ESTOQUE - {reportType === 'THINNER' ? 'DILUENTES' : 'TINTAS'}</div>
<div className="brand-subtitle">
Controle de {reportType === 'THINNER' ? 'Diluentes' : 'Tintas'} Agrupado por Produto
</div>
</div>
<div className="meta">
<div><strong>Data:</strong> {format(new Date(), 'dd/MM/yyyy')}</div>
<div><strong>Grupos Críticos:</strong> <span style={{ color: lowStockGroupsCount > 0 ? 'red' : 'inherit' }}>{lowStockGroupsCount}</span></div>
</div>
</header>
<section className="summary">
<div className="summary-item">
<div className="summary-label">Total de Lotes</div>
<div className="summary-value">{totalItems}</div>
<div className="summary-sub">Entradas ativas</div>
</div>
<div className="summary-item">
<div className="summary-label">Volume Total</div>
<div className="summary-value">{totalQuantity.toFixed(1)}</div>
<div className="summary-sub">Litros em estoque</div>
</div>
<div className="summary-item">
<div className="summary-label">Alertas de Estoque</div>
<div className="summary-value" style={{ color: lowStockGroupsCount > 0 ? '#ef4444' : '#10b981' }}>{lowStockGroupsCount}</div>
<div className="summary-sub">Produtos abaixo do mínimo</div>
</div>
<div className="summary-item">
<div className="summary-label">Vencidos</div>
<div className="summary-value" style={{ color: expiredItems > 0 ? '#ef4444' : 'inherit' }}>{expiredItems}</div>
<div className="summary-sub">Lotes expirados</div>
</div>
</section>
<div className="section-title">
<h2>DETALHAMENTO DO ESTOQUE</h2>
</div>
<table className="table">
<thead>
<tr>
<th className="col-obra" style={{ width: '35%' }}>Produto / RR</th>
<th className="col-cron" style={{ width: '20%' }}>Lote / Validade</th>
<th className="col-peso text-center" style={{ width: '15%' }}>Quantidade</th>
{reportType === 'PAINT' && <th className="col-tinta" style={{ width: '15%' }}>Cor</th>}
<th className="col-cor" style={{ width: '15%' }}>Nota Fiscal</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<React.Fragment key={group.key}>
{/* Group Header Row */}
<tr style={{ backgroundColor: '#f3f4f6', borderTop: '2px solid #e5e7eb', borderBottom: '1px solid #e5e7eb' }}>
<td colSpan={2} style={{ padding: '8px 12px' }}>
<div style={{ fontWeight: 'bold', fontSize: '10pt', color: '#111827' }}>
{group.productName.toUpperCase()}
</div>
<div style={{ fontSize: '7pt', color: '#6b7280' }}>
{group.manufacturer}
</div>
</td>
<td className="text-center" style={{ padding: '8px 12px' }}>
<div style={{ fontWeight: 'bold', fontSize: '10pt', color: group.isLowStock ? '#ef4444' : '#111827' }}>
{group.isLowStock && <span style={{ marginRight: '4px' }}></span>}
{group.totalQty.toFixed(1)} {group.unit}
</div>
{group.minStock > 0 && (
<div style={{ fontSize: '7pt', color: '#6b7280' }}>
Mín: {group.minStock}
</div>
)}
</td>
{reportType === 'PAINT' && (
<td style={{ padding: '8px 12px', verticalAlign: 'middle', fontWeight: 'bold', fontSize: '9pt', color: '#374151' }}>
{group.color}
</td>
)}
<td style={{ padding: '8px 12px', textAlign: 'right', fontSize: '8pt', color: '#6b7280' }}>
{group.items.length} lote(s)
</td>
</tr>
{/* Individual Items */}
{group.items.map((item) => {
const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date();
const itemMovements = movements.get(item._id!) || [];
return (
<React.Fragment key={item._id}>
<tr>
<td className="col-obra" style={{ paddingLeft: '24px' }}>
<div className="obra-nome" style={{ fontSize: '9pt' }}>RR: <strong>{item.rrNumber}</strong></div>
</td>
<td className="col-cron">
<div className="cron">
<strong>Lote:</strong> {item.batchNumber}<br />
{reportType === 'PAINT' && (
<>
<strong>Val:</strong>{' '}
<span style={{ color: isExpired ? '#ef4444' : 'inherit', fontWeight: isExpired ? 'bold' : 'normal' }}>
{item.expirationDate ? format(new Date(item.expirationDate), 'dd/MM/yyyy') : '-'}
</span>
</>
)}
</div>
</td>
<td className="col-peso text-center">
<div style={{ fontSize: '9pt' }}>
{item.quantity.toFixed(1)} {item.unit}
</div>
</td>
{reportType === 'PAINT' && (
<td className="col-tinta">
{/* Cor is already in group header, repeated here only if needed or keep empty/dash */}
<div style={{ opacity: 0.5 }}>-</div>
</td>
)}
<td className="col-cor">
<div style={{ fontSize: '8pt' }}>
{item.invoiceNumber || '-'}
</div>
{item.receivedBy && (
<div style={{ fontSize: '7pt', color: '#9ca3af' }}> Rec: {item.receivedBy}</div>
)}
</td>
</tr>
{/* Movimentações inline */}
{itemMovements.length > 0 && (
<tr>
<td colSpan={reportType === 'PAINT' ? 5 : 4} style={{ padding: '2px 24px 8px 24px', border: 'none' }}>
<div style={{ fontSize: '6.5pt', color: '#9ca3af', borderLeft: '2px solid #e5e7eb', paddingLeft: '8px' }}>
{itemMovements.slice(0, 5).map((mov, idx) => (
<span key={mov._id} style={{ marginRight: '8px', whiteSpace: 'nowrap' }}>
{format(new Date(mov.date), 'dd/MM/yy')} -{' '}
<strong>{formatMovementType(mov.type)}</strong>:{' '}
{mov.type === 'CONSUMPTION' ? '-' : '+'}{Math.abs(mov.quantity)}L
{mov.responsible && ` (${mov.responsible})`}
{idx < Math.min(itemMovements.length, 5) - 1 && ' | '}
</span>
))}
{itemMovements.length > 5 && (
<span style={{ fontStyle: 'italic', color: '#9ca3af' }}>
... +{itemMovements.length - 5} movimentações
</span>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</React.Fragment>
))}
</tbody>
</table>
<footer className="report-footer">
<div className="system-title">
SteelPaint - Gestão de Pintura Industrial
</div>
<div>
Gerado em {format(new Date(), 'dd/MM/yyyy')} às {format(new Date(), 'HH:mm')}h
</div>
<div className="sig-group">
<div className="sig-line">Responsável Estoque<span></span></div>
<div className="sig-line">Responsável Qualidade<span></span></div>
</div>
</footer>
</div>
);
};

View File

@@ -0,0 +1,151 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useUser, useOrganization } from '@clerk/clerk-react';
import type { AppUser } from '../types';
import { AuthContext } from './AuthContextType';
import { setApiClerkUserId, setApiOrganizationId, getBaseUrl } from '../services/api';
const API_URL = getBaseUrl();
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const { user, isLoaded } = useUser();
const { organization, membership } = useOrganization();
const [appUser, setAppUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const lastContextRef = useRef<{ clerkId?: string, orgId?: string | null }>({});
// Set the clerk user ID and organization ID for the API interceptor
useEffect(() => {
setApiClerkUserId(user?.id || null);
setApiOrganizationId(organization?.id || null);
}, [user?.id, organization?.id]);
const syncUser = useCallback(async () => {
if (!user) {
setAppUser(null);
setIsLoading(false);
return;
}
try {
// Only set loading if the context has changed (new user or new organization)
// This prevents unmounting/remounting components on window focus revalidations
const isSameContext =
lastContextRef.current.clerkId === user.id &&
lastContextRef.current.orgId === (organization?.id || null);
if (!isSameContext) {
setIsLoading(true);
}
setError(null);
// Sync user with backend, including organization context
const response = await fetch(`${API_URL}/users/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-clerk-user-id': user.id,
...(organization?.id && { 'x-organization-id': organization.id }),
},
body: JSON.stringify({
clerkId: user.id,
email: user.primaryEmailAddress?.emailAddress || '',
name: user.fullName || user.firstName || 'Usuário',
organizationId: organization?.id || null,
clerkRole: membership?.role || null, // org:admin, org:member, etc.
}),
});
if (!response.ok) {
const data = await response.json();
if (response.status === 403 && data.error?.includes('bloqueada')) {
setError('Sua conta foi bloqueada. Entre em contato com o administrador.');
setAppUser(null);
return;
}
throw new Error('Falha ao sincronizar usuário');
}
const syncedUser = await response.json();
// Use organizationRole if available (per-org role), otherwise fall back to global role
const effectiveRole = syncedUser.organizationRole || syncedUser.role || 'guest';
setAppUser({
...syncedUser,
id: syncedUser._id || syncedUser.id,
role: effectiveRole, // Override with organization-specific role
});
// Update last context ref
lastContextRef.current = { clerkId: user.id, orgId: organization?.id || null };
} catch (err) {
console.error('Error syncing user:', err);
setError('Erro ao carregar dados do usuário');
} finally {
setIsLoading(false);
}
}, [user, organization?.id, membership?.role]);
const refetchUser = useCallback(async () => {
if (!user) return;
try {
const response = await fetch(`${API_URL}/users/me`, {
headers: {
'x-clerk-user-id': user.id,
...(organization?.id && { 'x-organization-id': organization.id }),
},
});
if (response.ok) {
const userData = await response.json();
const effectiveRole = userData.organizationRole || userData.role || 'guest';
setAppUser({
...userData,
id: userData._id || userData.id,
role: effectiveRole,
});
}
} catch (err) {
console.error('Error refetching user:', err);
}
}, [user, organization?.id]);
// Re-sync when organization changes
useEffect(() => {
if (isLoaded && user) {
syncUser();
}
}, [isLoaded, user, organization?.id, syncUser]);
const isDeveloper = useCallback(() => {
return user?.primaryEmailAddress?.emailAddress === 'admtracksteel@gmail.com';
}, [user]);
const isAdmin = useCallback(() => appUser?.role === 'admin' || isDeveloper(), [appUser, isDeveloper]);
const isUser = useCallback(() => appUser?.role === 'user' || isAdmin(), [appUser, isAdmin]);
const isGuest = useCallback(() => appUser?.role === 'guest' && !isDeveloper(), [appUser, isDeveloper]);
const canEdit = useCallback(() => (appUser?.role !== 'guest' && appUser?.role !== undefined) || isDeveloper(), [appUser, isDeveloper]);
return (
<AuthContext.Provider
value={{
appUser,
isLoading,
isSignedIn: !!user,
error,
isAdmin,
isUser,
isGuest,
isDeveloper,
canEdit,
refetchUser,
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,17 @@
import { createContext } from 'react';
import type { AppUser } from '../types';
export interface AuthContextType {
appUser: AppUser | null;
isLoading: boolean;
isSignedIn: boolean;
error: string | null;
isAdmin: () => boolean;
isUser: () => boolean;
isGuest: () => boolean;
isDeveloper: () => boolean;
canEdit: () => boolean;
refetchUser: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);

View File

@@ -0,0 +1,56 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { systemSettingsService } from '../services/systemSettingsService';
import type { SystemSettings } from '../services/systemSettingsService';
interface SystemSettingsContextType {
settings: SystemSettings | null;
isLoading: boolean;
updateSettings: (newSettings: Partial<SystemSettings>) => Promise<void>;
}
const SystemSettingsContext = createContext<SystemSettingsContextType | undefined>(undefined);
export const SystemSettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [settings, setSettings] = useState<SystemSettings | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchSettings = useCallback(async () => {
try {
const data = await systemSettingsService.getSettings();
setSettings(data);
} catch (error) {
console.error('Failed to load system settings:', error);
// Set defaults if fetch fails
setSettings({
settingsId: 'global',
appName: 'GPI',
appSubtitle: 'Gestão de Pintura Industrial'
});
} finally {
setIsLoading(false);
}
}, []);
const updateSettingsValue = async (newSettings: Partial<SystemSettings>) => {
const updated = await systemSettingsService.updateSettings(newSettings);
setSettings(updated);
};
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
return (
<SystemSettingsContext.Provider value={{ settings, isLoading, updateSettings: updateSettingsValue }}>
{children}
</SystemSettingsContext.Provider>
);
};
export const useSystemSettings = () => {
const context = useContext(SystemSettingsContext);
if (!context) {
throw new Error('useSystemSettings must be used within a SystemSettingsProvider');
}
return context;
};

View File

@@ -0,0 +1,22 @@
import React, { createContext, useContext } from 'react';
export interface Toast {
id: string;
message: string;
type: 'warning' | 'error' | 'success' | 'info';
}
export interface ToastContextType {
showToast: (message: string, type?: Toast['type']) => void;
showGuestWarning: () => void;
}
export const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { AuthContext } from './AuthContextType';
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,103 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@clerk/clerk-react';
import api from '../services/api';
import type { INotification } from '../types';
import { NotificationContext } from './NotificationContextState';
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { orgId, isSignedIn } = useAuth();
const [notifications, setNotifications] = useState<INotification[]>([]);
const [loading, setLoading] = useState(false);
const fetchNotifications = useCallback(async () => {
if (!orgId || !isSignedIn) return;
try {
if (notifications.length === 0) setLoading(true);
const response = await api.get('/notifications');
setNotifications(response.data);
} catch (error) {
console.error('Failed to fetch notifications', error);
} finally {
setLoading(false);
}
}, [orgId, isSignedIn, notifications.length]);
const markAsRead = async (id: string) => {
try {
await api.put(`/notifications/${id}/read`);
setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n));
} catch (error) {
console.error('Failed to mark as read', error);
}
};
const markAllAsRead = async () => {
try {
await api.put('/notifications/read-all');
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
} catch (error) {
console.error('Failed to mark all as read', error);
}
}
const clearAll = async () => {
try {
await api.delete('/notifications/clear-all');
setNotifications([]);
} catch (error) {
console.error('Failed to clear all notifications', error);
}
};
const archiveNotification = async (id: string) => {
try {
await api.patch(`/notifications/${id}/archive`);
setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) {
console.error('Failed to archive notification', error);
}
};
const deleteNotification = async (id: string) => {
try {
await api.delete(`/notifications/${id}`);
setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) {
console.error('Failed to delete notification', error);
}
};
// Polling effect
useEffect(() => {
if (isSignedIn && orgId) {
fetchNotifications(); // Initial fetch
const interval = setInterval(() => {
fetchNotifications();
}, 30000); // Poll every 30 seconds
return () => clearInterval(interval);
} else {
setNotifications([]);
}
}, [isSignedIn, orgId, fetchNotifications]);
const unreadCount = notifications.filter(n => !n.isRead).length;
return (
<NotificationContext.Provider value={{
notifications,
unreadCount,
loading,
markAsRead,
markAllAsRead,
clearAll,
archiveNotification,
deleteNotification,
fetchNotifications
}}>
{children}
</NotificationContext.Provider>
);
};

View File

@@ -0,0 +1,16 @@
import { createContext } from 'react';
import type { INotification } from '../types';
export interface NotificationContextData {
notifications: INotification[];
unreadCount: number;
loading: boolean;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
clearAll: () => Promise<void>;
archiveNotification: (id: string) => Promise<void>;
deleteNotification: (id: string) => Promise<void>;
fetchNotifications: () => Promise<void>;
}
export const NotificationContext = createContext<NotificationContextData>({} as NotificationContextData);

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { NotificationContext } from '../contexts/NotificationContextState';
export const useNotifications = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotifications must be used within a NotificationProvider');
}
return context;
};

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback } from 'react';
import api from '../services/api';
import { useAuth } from '../context/useAuth';
export interface ActiveUser {
_id: string;
name: string;
email: string;
clerkId: string;
lastSeenAt: string;
}
export const usePresence = () => {
const { isSignedIn, appUser, isLoading } = useAuth();
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>([]);
const sendHeartbeat = useCallback(async () => {
// Only send heartbeat if user is signed in, not loading, and appUser exists
if (!isSignedIn || isLoading || !appUser) return;
try {
await api.post('/users/heartbeat');
} catch (error) {
console.error('Failed to send heartbeat', error);
}
}, [isSignedIn, isLoading, appUser]);
const fetchActiveUsers = useCallback(async () => {
// Only fetch if user is signed in, not loading, and appUser exists
if (!isSignedIn || isLoading || !appUser) return;
try {
const response = await api.get<ActiveUser[]>('/users/active');
setActiveUsers(response.data);
} catch (error) {
console.error('Failed to fetch active users', error);
}
}, [isSignedIn, isLoading, appUser]);
useEffect(() => {
// Wait until user is signed in, not loading, and appUser exists
if (!isSignedIn || isLoading || !appUser) return;
// Initial call
sendHeartbeat();
fetchActiveUsers();
// Interval
const interval = setInterval(() => {
sendHeartbeat();
fetchActiveUsers();
}, 60000); // 1 minute
return () => clearInterval(interval);
}, [isSignedIn, isLoading, appUser, sendHeartbeat, fetchActiveUsers]);
return { activeUsers };
};

View File

@@ -0,0 +1,2 @@
// Re-export from the new context file to maintain backward compatibility
export { useToast } from '../context/ToastContext';

View File

@@ -0,0 +1,45 @@
import { useState, useEffect } from 'react';
import api from '../services/api';
import { useAuth } from '../context/useAuth';
export const useUnreadMessages = () => {
const { isSignedIn, appUser } = useAuth();
const [unreadCount, setUnreadCount] = useState(0);
const [hasUnread, setHasUnread] = useState(false);
useEffect(() => {
if (!isSignedIn || !appUser) return;
const fetchUnreadCount = async () => {
try {
const response = await api.get('/messages/unread');
const count = response.data.length;
setUnreadCount(count);
setHasUnread(count > 0);
} catch (error) {
console.error('Error fetching unread messages:', error);
}
};
// Initial fetch
fetchUnreadCount();
// Poll every 30 seconds
const interval = setInterval(fetchUnreadCount, 30000);
return () => clearInterval(interval);
}, [isSignedIn, appUser]);
const refreshUnreadCount = async () => {
try {
const response = await api.get('/messages/unread');
const count = response.data.length;
setUnreadCount(count);
setHasUnread(count > 0);
} catch (error) {
console.error('Error fetching unread messages:', error);
}
};
return { unreadCount, hasUnread, refreshUnreadCount };
};

219
src/client/index.css Normal file
View File

@@ -0,0 +1,219 @@
@import "tailwindcss";
@import "https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap";
@theme {
--font-sans: "IBM Plex Sans", system-ui, sans-serif;
--font-display: "IBM Plex Sans", system-ui, sans-serif;
--color-primary: var(--primary);
--color-accent: var(--accent);
--color-surface: var(--surface);
--color-surface-soft: var(--surface-soft);
--color-surface-hover: var(--surface-hover);
--color-surface-highlight: var(--surface-highlight);
--color-card-dark: var(--card-dark);
--color-text-main: var(--text-main);
--color-text-secondary: var(--text-secondary);
--color-text-muted: var(--text-muted);
--color-border: var(--border);
--color-success: var(--success);
--color-error: var(--error);
--color-warning: var(--warning);
--shadow-soft: 0 10px 40px -6px rgba(180, 150, 100, 0.15);
--shadow-premium: 0 20px 60px -12px rgba(180, 150, 100, 0.2);
--radius-xl: 0.75rem;
--radius-2xl: 1.25rem;
--radius-3xl: 2rem;
--tracking-tight: -0.025em;
--tracking-wide: 0.05em;
}
@layer base {
:root {
--primary: #fb923c;
--primary-light: #fdba74;
--accent: #f97316;
--accent-green: #22c55e;
--background-light: #fffcf0;
--surface: #ffffff;
--surface-soft: #fffbeb;
/* Amber-50 - Fundo da página */
--surface-hover: #fef3c7;
/* Amber-100 */
--surface-highlight: #ffedd5;
/* Amber-100ish */
--text-main: #1c1917;
--text-secondary: #57534e;
--text-muted: #a8a29e;
--border: #fde6d2;
/* Tom de borda pêssego/creme muito suave */
--success: #10b981;
--error: #ef4444;
--warning: #f59e0b;
/* Input Custom Theme */
--input-bg: #fffbeb;
/* amber-50 */
--input-border: #fed7aa;
/* amber-200 */
--input-text: #1c1917;
/* stone-900 */
--input-placeholder: #a8a29e;
}
.dark {
--primary: #fb923c;
--primary-light: #7c2d12;
--accent: #f97316;
--accent-green: #22c55e;
--background-light: #1c1917;
--background-dark: #0c0a09;
--card-dark: #292524;
--surface: #292524;
--surface-soft: #1c1917;
--surface-hover: #44403c;
--surface-highlight: #57534e;
--text-main: #fafaf9;
--text-secondary: #d6d3d1;
--text-muted: #a8a29e;
--border: #44403c;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
/* Input Custom Theme - Dark */
--input-bg: rgba(12, 10, 9, 0.4);
--input-border: rgba(255, 255, 255, 0.1);
--input-text: #fafaf9;
--input-placeholder: #57534e;
}
body {
@apply bg-surface-soft font-sans text-text-main antialiased transition-colors duration-300;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-display tracking-tight text-text-main;
}
a,
button,
input,
select,
textarea {
@apply transition-all duration-200 ease-in-out;
}
}
@layer components {
.glass-card {
@apply bg-card-dark/60 backdrop-blur-xl border border-white/5 shadow-xl transition-all duration-300 overflow-hidden;
@apply hover:border-primary/30;
}
}
/* Date input calendar icon styling for dark mode */
.dark input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(0.7) sepia(1) saturate(5) hue-rotate(-10deg);
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.dark input[type="date"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* Light mode - subtle styling */
input[type="date"]::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s ease;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* Select dropdown styling for dark mode */
.dark select {
color-scheme: dark;
}
.dark select option {
background-color: #292524;
color: #fafaf9;
}
.dark select option:hover,
.dark select option:focus,
.dark select option:checked {
background-color: #fb923c;
color: white;
}
/* Light mode - select dropdown styling */
select option {
background-color: #fff7ed;
color: #1c1917;
}
select option:hover,
select option:focus,
select option:checked {
background-color: #fb923c;
color: white;
}
/* Clerk Branding Removal & UI Harmonization */
.cl-internal-ph60ov,
.cl-internal-1dauvpw,
.cl-internal-1fke6u8,
[data-clerk-popover-footer],
.cl-card > div:last-child:not(.cl-main) {
display: none !important;
}
/* Clerk Dark Mode Contrast Override */
.dark .cl-organizationSwitcherPopoverCard,
.dark .cl-card {
background-color: var(--surface) !important;
border: 1px solid var(--border) !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important;
}
.dark .cl-organizationPreviewMainIdentifier,
.dark .cl-userPreviewMainIdentifier {
color: var(--text-main) !important;
}
.dark .cl-organizationPreviewSecondaryIdentifier,
.dark .cl-userPreviewSecondaryIdentifier,
.dark .cl-organizationSwitcherPopoverActionButtonIcon {
color: var(--text-muted) !important;
}
.dark .cl-organizationSwitcherPopoverActionButton:hover {
background-color: var(--surface-hover) !important;
}
.dynamic-bg-color {
background-color: var(--bg-color, #ffffff);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.animate-blink {
animation: blink 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

47
src/client/main.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/clerk-react'
import { ptBR } from '@clerk/localizations'
import './index.css'
import App from './App.tsx'
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
if (!PUBLISHABLE_KEY) {
throw new Error("Missing Publishable Key")
}
createRoot(document.getElementById('root')!).render(
<ClerkProvider
publishableKey={PUBLISHABLE_KEY}
afterSignOutUrl="/"
localization={ptBR}
appearance={{
variables: {
colorPrimary: '#fb923c', // Cor primária do GPI (Laranja)
colorBackground: '#ffffff',
colorText: '#1c1917',
colorTextSecondary: '#57534e',
borderRadius: '0.75rem',
},
elements: {
card: "shadow-none border-0 bg-transparent", // Deixamos o container da página controlar o card
navbar: "hidden",
headerTitle: "text-2xl font-bold tracking-tight",
headerSubtitle: "text-text-muted font-medium",
formButtonPrimary: "bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-primary/20",
socialButtonsBlockButton: "bg-white hover:bg-surface-hover border-border/40 text-text-main font-semibold transition-all duration-300 rounded-xl",
footerActionLink: "text-primary hover:text-primary/80 font-bold",
formFieldInput: "bg-surface-soft border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl",
organizationSwitcherTrigger: "hover:bg-surface-hover transition-colors rounded-xl",
organizationPreviewMainIdentifier: "font-bold",
// Personalização específica para a lista de organizações que aparece na imagem
organizationListPreview: "hover:bg-surface-soft rounded-xl transition-all p-3",
organizationListCreateOrganizationButton: "text-primary font-bold hover:text-primary/80",
}
}}
>
<App />
</ClerkProvider>,
)

View File

@@ -0,0 +1,612 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useUser, useOrganization } from '@clerk/clerk-react';
import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Upload, Info, Image as ImageIcon, Box, Database } from 'lucide-react';
import { clsx } from 'clsx';
import type { AppUser, UserRole } from '../types';
import { useAuth } from '../context/useAuth';
import api from '../services/api';
import { GeometrySettings } from '../components/admin/GeometrySettings';
import { BackupRestore } from '../components/admin/BackupRestore';
const roleLabels: Record<UserRole, { label: string; color: string; icon: React.ReactNode }> = {
admin: { label: 'Administrador', color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', icon: <Crown size={14} /> },
user: { label: 'Usuário', color: 'bg-primary/20 text-primary border-primary/30', icon: <UserIcon size={14} /> },
guest: { label: 'Convidado', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: <Eye size={14} /> },
};
export const AdminDashboard: React.FC = () => {
const { user } = useUser();
const { organization } = useOrganization();
const { isAdmin } = useAuth();
const [users, setUsers] = useState<AppUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filterRole, setFilterRole] = useState<UserRole | 'all'>('all');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'users' | 'organization' | 'settings' | 'stock' | 'backup'>('users');
const [logoLoading, setLogoLoading] = useState(false);
const fetchUsers = useCallback(async () => {
if (!user || !organization?.id) return;
try {
setIsLoading(true);
const response = await api.get('/users');
setUsers(response.data.map((u: AppUser) => ({ ...u, id: u._id || u.id })));
} catch (error) {
console.error('Error fetching users:', error);
} finally {
setIsLoading(false);
}
}, [user, organization?.id]);
const syncOrganizationMembers = useCallback(async () => {
if (!organization) return;
try {
setIsLoading(true);
// Fetch ALL members from Clerk (handle pagination)
console.log('Fetching members from Clerk organization:', organization.id);
let allMembers: any[] = [];
let hasMore = true;
// Fetch all pages
while (hasMore) {
const clerkMembers = await organization.getMemberships();
console.log(`Fetched members:`, clerkMembers.data.length);
if (clerkMembers.data.length === 0) {
hasMore = false;
} else {
allMembers = clerkMembers.data;
hasMore = false; // Clerk retorna todos de uma vez normalmente
}
}
console.log('Total Clerk members fetched:', allMembers.length, allMembers);
// Get current users from database
const currentUsersResponse = await api.get('/users');
const currentUsers = currentUsersResponse.data;
console.log('Current users in database:', currentUsers.length, currentUsers);
// Create a Set of Clerk user IDs for fast lookup
const clerkUserIds = new Set(
allMembers
.map(m => m.publicUserData?.userId)
.filter(id => id != null)
);
console.log('Clerk user IDs:', Array.from(clerkUserIds));
// Step 1: Add/Update users from Clerk
for (const membership of allMembers) {
const clerkUser = membership.publicUserData;
console.log('Processing membership:', membership);
console.log('Public user data:', clerkUser);
if (clerkUser) {
const syncData = {
clerkId: clerkUser.userId,
email: clerkUser.identifier || '',
name: `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim() || clerkUser.identifier || 'Usuário',
organizationId: organization.id,
clerkRole: membership.role
};
console.log('Syncing user:', syncData);
try {
const response = await api.post('/users/sync', syncData);
console.log('Sync success for', clerkUser.userId, ':', response.data);
} catch (syncError) {
console.error('Error syncing member:', clerkUser.userId, syncError);
}
}
}
// Step 2: Remove users from database that don't exist in Clerk anymore
let removedCount = 0;
for (const dbUser of currentUsers) {
const clerkUserId = dbUser.clerkUserId || dbUser.clerkId;
if (!clerkUserIds.has(clerkUserId)) {
console.log(`User ${dbUser.name} (${clerkUserId}) is in DB but not in Clerk - removing...`);
try {
await api.delete(`/users/${dbUser._id}`);
console.log(`Removed user ${dbUser.name} from database`);
removedCount++;
} catch (deleteError) {
console.error(`Error removing user ${dbUser.name}:`, deleteError);
}
}
}
// Reload users after sync
console.log('Reloading users from database...');
await fetchUsers();
const message = `Sincronização concluída!\n✅ ${allMembers.length} membros atualizados\n${removedCount > 0 ? `🗑️ ${removedCount} membros removidos` : ''}`;
alert(message);
} catch (error) {
console.error('Error syncing organization members:', error);
alert('Erro ao sincronizar membros. Verifique o console para mais detalhes.');
} finally {
setIsLoading(false);
}
}, [organization, fetchUsers]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleRoleChange = async (userId: string, newRole: UserRole) => {
if (!user) return;
setActionLoading(userId);
try {
const response = await api.patch(`/users/${userId}/role`, { role: newRole });
const updated = response.data;
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } } };
console.error('Error updating role:', error);
alert(err.response?.data?.error || 'Erro ao atualizar role');
} finally {
setActionLoading(null);
}
};
const handleToggleBan = async (userId: string, isBanned: boolean) => {
if (!user) return;
setActionLoading(userId);
try {
const response = await api.patch(`/users/${userId}/ban`, { isBanned });
const updated = response.data;
setUsers(users.map(u => u.id === userId ? { ...updated, id: updated._id || updated.id } : u));
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } } };
console.error('Error toggling ban:', error);
alert(err.response?.data?.error || 'Erro ao alterar status');
} finally {
setActionLoading(null);
}
};
const filteredUsers = users.filter(u => {
const matchesSearch = u.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
const matchesRole = filterRole === 'all' || u.role === filterRole;
return matchesSearch && matchesRole;
});
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !organization) return;
// Validations
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'];
if (!validTypes.includes(file.type)) {
alert('Por favor, selecione uma imagem PNG, JPG ou SVG.');
return;
}
if (file.size > 500 * 1024) {
alert('O arquivo deve ter no máximo 500KB.');
return;
}
setLogoLoading(true);
try {
await organization.setLogo({ file });
alert('Logo atualizado com sucesso!');
} catch (error) {
console.error('Error uploading logo:', error);
alert('Erro ao atualizar o logo.');
} finally {
setLogoLoading(false);
}
};
if (!isAdmin()) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<Shield size={64} className="text-error/50" />
<h1 className="text-2xl font-bold text-text-main">Acesso Negado</h1>
<p className="text-text-muted">Você não tem permissão para acessar esta página.</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-black text-text-main tracking-tight flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-lg shadow-amber-500/30">
<Shield size={24} className="text-white" />
</div>
Administração
</h1>
<p className="text-text-muted mt-2">Configurações globais e gerenciamento de usuários</p>
</div>
{activeTab === 'users' && (
<div className="flex gap-2">
<button
onClick={syncOrganizationMembers}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white border border-primary-dark rounded-xl font-semibold transition-all disabled:opacity-50"
>
<Users size={18} className={isLoading ? 'animate-spin' : ''} />
Sincronizar Clerk
</button>
<button
onClick={fetchUsers}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-surface hover:bg-surface-hover border border-border/40 rounded-xl text-text-main font-semibold transition-all disabled:opacity-50"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
Atualizar
</button>
</div>
)}
</div>
{/* Tabs Navigation */}
<div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 w-fit">
<button
onClick={() => setActiveTab('users')}
className={clsx(
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'users'
? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Users size={16} />
Usuários
</button>
<button
onClick={() => setActiveTab('organization')}
className={clsx(
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'organization'
? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Upload size={16} />
Organização
</button>
<button
onClick={() => setActiveTab('settings')}
className={clsx(
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'settings'
? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Box size={16} />
Geometrias
</button>
<button
onClick={() => setActiveTab('backup')}
className={clsx(
"flex items-center gap-2 px-6 py-2.5 text-sm font-bold rounded-lg transition-all",
activeTab === 'backup'
? "bg-surface text-primary shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Database size={16} />
Backup
</button>
</div>
{activeTab === 'users' ? (
<>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-surface rounded-2xl p-5 border border-border/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center">
<Users size={20} className="text-primary" />
</div>
<div>
<p className="text-2xl font-black text-text-main">{users.length}</p>
<p className="text-xs text-text-muted font-medium">Total</p>
</div>
</div>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center">
<Crown size={20} className="text-amber-400" />
</div>
<div>
<p className="text-2xl font-black text-text-main">{users.filter(u => u.role === 'admin').length}</p>
<p className="text-xs text-text-muted font-medium">Admins</p>
</div>
</div>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-green-500/20 flex items-center justify-center">
<UserCheck size={20} className="text-green-400" />
</div>
<div>
<p className="text-2xl font-black text-text-main">{users.filter(u => u.role === 'user').length}</p>
<p className="text-xs text-text-muted font-medium">Usuários</p>
</div>
</div>
</div>
<div className="bg-surface rounded-2xl p-5 border border-border/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
<UserX size={20} className="text-red-400" />
</div>
<div>
<p className="text-2xl font-black text-text-main">{users.filter(u => u.isBanned).length}</p>
<p className="text-xs text-text-muted font-medium">Banidos</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="Buscar por nome ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-12 pr-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/>
</div>
<select
value={filterRole}
onChange={(e) => setFilterRole(e.target.value as UserRole | 'all')}
aria-label="Filtrar por role"
className="px-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
>
<option value="all">Todos os Roles</option>
<option value="admin">Administradores</option>
<option value="user">Usuários</option>
<option value="guest">Convidados</option>
</select>
</div>
{/* Users Table */}
<div className="bg-surface rounded-2xl border border-border/40 overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<RefreshCw size={32} className="animate-spin text-primary" />
</div>
) : filteredUsers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
<Users size={48} className="mb-4 opacity-50" />
<p>Nenhum usuário encontrado</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border/40 bg-surface-soft">
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Usuário</th>
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Email</th>
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Role</th>
<th className="text-left px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Status</th>
<th className="text-right px-6 py-4 text-xs font-bold text-text-muted uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{filteredUsers.map((u) => {
const roleInfo = roleLabels[u.role];
const isCurrentUser = u.clerkId === user?.id;
const isActionDisabled = actionLoading === u.id;
return (
<tr key={u.id} className={`hover:bg-surface-hover transition-colors ${u.isBanned ? 'opacity-60' : ''}`}>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">
{u.name.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-semibold text-text-main">{u.name}</p>
{isCurrentUser && (
<span className="text-xs text-primary font-medium">(Você)</span>
)}
</div>
</div>
</td>
<td className="px-6 py-4 text-text-secondary">{u.email}</td>
<td className="px-6 py-4">
<select
value={u.role}
onChange={(e) => handleRoleChange(u.id, e.target.value as UserRole)}
disabled={isCurrentUser || isActionDisabled || u.isBanned}
aria-label={`Alterar role de ${u.name}`}
className={`px-3 py-1.5 rounded-lg border text-sm font-semibold transition-all ${roleInfo.color} disabled:opacity-50 disabled:cursor-not-allowed bg-transparent`}
>
<option value="guest">Convidado</option>
<option value="user">Usuário</option>
<option value="admin">Administrador</option>
</select>
</td>
<td className="px-6 py-4">
{u.isBanned ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 text-sm font-semibold">
<UserX size={14} />
Banido
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 text-sm font-semibold">
<UserCheck size={14} />
Ativo
</span>
)}
</td>
<td className="px-6 py-4 text-right">
{!isCurrentUser && u.role !== 'admin' && (
<button
onClick={() => handleToggleBan(u.id, !u.isBanned)}
disabled={isActionDisabled}
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all ${u.isBanned
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
} disabled:opacity-50`}
>
{isActionDisabled ? (
<RefreshCw size={16} className="animate-spin" />
) : u.isBanned ? (
'Desbanir'
) : (
'Banir'
)}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</>
) : activeTab === 'organization' ? (
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
{/* Organization Settings Panel */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-border/20">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Upload size={20} className="text-primary" />
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Identidade Visual</h2>
<p className="text-xs text-text-muted">Gerencie o logo da sua organização</p>
</div>
</div>
<div className="flex flex-col items-center gap-6 py-4">
{organization?.imageUrl ? (
<div className="relative group">
<div className="w-32 h-32 rounded-2xl border-2 border-primary/20 p-2 bg-white overflow-hidden shadow-xl">
<img
src={organization.imageUrl}
alt={organization.name}
className="w-full h-full object-contain"
/>
</div>
<div className="absolute -bottom-2 -right-2 bg-primary text-white p-2 rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity">
<ImageIcon size={14} />
</div>
</div>
) : (
<div className="w-32 h-32 rounded-2xl border-2 border-dashed border-border/40 flex flex-col items-center justify-center bg-surface-soft text-text-muted gap-2">
<ImageIcon size={32} className="opacity-20" />
<span className="text-[10px] font-bold uppercase tracking-widest">Sem Logo</span>
</div>
)}
<div className="w-full space-y-4">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-border/40 rounded-2xl cursor-pointer hover:bg-surface-hover hover:border-primary/50 transition-all group">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 text-text-muted group-hover:text-primary transition-colors mb-2" />
<p className="text-sm text-text-main font-bold">Clique para alterar o logo</p>
<p className="text-xs text-text-muted">ou arraste e solte o arquivo</p>
</div>
<input
type="file"
className="hidden"
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
onChange={handleLogoUpload}
disabled={logoLoading}
/>
</label>
{logoLoading && (
<div className="flex items-center justify-center gap-2 text-primary font-bold animate-pulse">
<RefreshCw size={16} className="animate-spin" />
<span>Enviando logo...</span>
</div>
)}
</div>
</div>
</div>
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-border/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center">
<Info size={20} className="text-amber-500" />
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Requisitos & Dicas</h2>
<p className="text-xs text-text-muted">Regras para um visual impecável</p>
</div>
</div>
<div className="space-y-4">
<div className="p-4 bg-surface-soft rounded-xl border border-border/20">
<h3 className="text-sm font-bold text-text-main flex items-center gap-2 mb-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary"></div>
Formatos Suportados
</h3>
<p className="text-xs text-text-muted leading-relaxed">
Aceitamos arquivos nos formatos <strong>PNG, JPG ou SVG</strong>. O formato SVG é recomendado para máxima nitidez em qualquer tamanho.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/20">
<h3 className="text-sm font-bold text-text-main flex items-center gap-2 mb-2">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500"></div>
Dimensões Recomendadas
</h3>
<p className="text-xs text-text-muted leading-relaxed">
Recomendamos uma imagem quadrada de no mínimo <strong>512x512 pixels</strong>. Logos horizontais podem não aparecer corretamente em todas as áreas.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/20">
<h3 className="text-sm font-bold text-text-main flex items-center gap-2 mb-2">
<div className="w-1.5 h-1.5 rounded-full bg-red-500"></div>
Limite de Tamanho
</h3>
<p className="text-xs text-text-muted leading-relaxed">
O arquivo não deve ultrapassar <strong>500 KB</strong>. Arquivos maiores serão rejeitados automaticamente para garantir rapidez no carregamento.
</p>
</div>
</div>
</div>
</div>
</div>
) : activeTab === 'settings' ? (
<GeometrySettings />
) : activeTab === 'backup' ? (
<BackupRestore />
) : (
// Lazily load or direct render StockDashboard (Need to import it)
<div className="bg-surface rounded-2xl border border-border/40 p-6">
<div className="text-center py-10">
<h2 className="text-xl font-bold text-text-main">Gestão de Estoque</h2>
<p className="text-text-muted mt-2">Acesse a nova página dedicada ao controle de estoque.</p>
<a
href="/stock"
className="mt-6 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
Ir para Estoque
</a>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import { getProjectAnalysis, type AnalysisResult } from '../services/analysisService';
import { AlertCircle, CheckCircle, XCircle } from 'lucide-react';
interface Props {
projectId: string;
}
export const AnalysisDashboard: React.FC<Props> = ({ projectId }) => {
const [analysis, setAnalysis] = useState<AnalysisResult[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchAnalysis = async () => {
try {
const response = await getProjectAnalysis(projectId);
setAnalysis(response.data);
setLoading(false);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Erro ao carregar análise';
setError(errorMessage);
setLoading(false);
}
};
fetchAnalysis();
}, [projectId]);
if (loading) return <div className="text-center p-8">Carregando análise...</div>;
if (error) return <div className="text-error p-4">{error}</div>;
if (analysis.length === 0) {
return (
<div className="text-center p-8 text-text-secondary">
Nenhum dado suficiente para análise. Cadastre registros de aplicação e esquemas de pintura.
</div>
);
}
const renderStatusIcon = (status: 'approved' | 'warning' | 'critical') => {
if (status === 'approved') return <CheckCircle className="text-success w-5 h-5" />;
if (status === 'warning') return <AlertCircle className="text-warning w-5 h-5" />;
return <XCircle className="text-error w-5 h-5" />;
};
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-text-main mb-2">Relatório de Conformidade Técnica</h2>
<p className="text-text-secondary">Comparativo entre valores teóricos (Esquema) e reais (Aplicação/Inspeção)</p>
</div>
<div className="bg-surface rounded-xl shadow-sm border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-surface-hover text-text-secondary text-sm font-medium">
<tr>
<th className="p-4">Peça / Lote</th>
<th className="p-4">Esquema</th>
<th className="p-4 text-center">Rendimento (m²/L)</th>
<th className="p-4 text-center">Diluição (%)</th>
<th className="p-4 text-center">Espessura (μm)</th>
<th className="p-4 text-center">Status Global</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{analysis.map((item, index) => (
<tr key={index} className="hover:bg-surface-soft transition-colors">
<td className="p-4 font-medium text-text-main">{item.pieceDescription}</td>
<td className="p-4 text-text-secondary text-sm">
{item.schemeName}
<div className="text-xs text-text-muted">{item.schemeType}</div>
</td>
{/* Yield */}
<td className="p-4 text-center">
<div className="flex flex-col items-center">
<div className="flex items-center gap-2">
<span className="font-semibold">{item.realYield}</span>
{renderStatusIcon(item.yieldStatus)}
</div>
<span className="text-xs text-text-muted">Meta: {item.theoreticalYield}</span>
<span className={`text-xs ${item.yieldStatus === 'critical' ? 'text-error' : item.yieldStatus === 'warning' ? 'text-warning' : 'text-success'}`}>
{item.yieldVariance > 0 ? '+' : ''}{item.yieldVariance}%
</span>
</div>
</td>
{/* Dilution */}
<td className="p-4 text-center">
<div className="flex flex-col items-center">
<div className="flex items-center gap-2">
<span className="font-semibold">{item.realDilution}%</span>
{renderStatusIcon(item.dilutionStatus)}
</div>
<span className="text-xs text-text-muted">Max: {item.targetDilution}%</span>
</div>
</td>
{/* DFT */}
<td className="p-4 text-center">
<div className="flex flex-col items-center">
<div className="flex items-center gap-2">
<span className="font-semibold">{item.realDFT}</span>
{renderStatusIcon(item.dftStatus)}
</div>
<span className="text-xs text-text-muted">{item.minDFT} - {item.maxDFT}</span>
</div>
</td>
{/* Overall Status */}
<td className="p-4 text-center">
{(item.yieldStatus === 'critical' || item.dftStatus === 'critical') ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error/10 text-error">
REPROVADO
</span>
) : (item.yieldStatus === 'warning' || item.dftStatus === 'warning') ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning">
ATENÇÃO
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success">
APROVADO
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-text-secondary">
<div className="bg-surface p-4 rounded-lg border border-border">
<div className="flex items-center gap-2 mb-2 font-medium text-text-main">
<CheckCircle className="text-success w-4 h-4" /> Aprovado
</div>
Variação tolerável (até 20%) em relação aos parâmetros teóricos e normas.
</div>
<div className="bg-surface p-4 rounded-lg border border-border">
<div className="flex items-center gap-2 mb-2 font-medium text-text-main">
<AlertCircle className="text-warning w-4 h-4" /> Atenção
</div>
Variação moderada (20-30%). Requer acompanhamento técnico para ajustes.
</div>
<div className="bg-surface p-4 rounded-lg border border-border">
<div className="flex items-center gap-2 mb-2 font-medium text-text-main">
<XCircle className="text-error w-4 h-4" /> Crítico
</div>
Desvio significativo (&gt;30%) ou fora das normas. Risco de falha do revestimento.
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,496 @@
import React, { useState, useMemo } from 'react';
import { Card } from '../components/Card';
import { Button } from '../components/Button';
import { Ruler, Droplets, CloudRain, PiggyBank, Paintbrush, ArrowRightLeft, Info, Calculator, Layers, HelpCircle } from 'lucide-react';
import { clsx } from 'clsx';
interface InputGroupProps {
label: React.ReactNode;
value: string;
onChange: (value: string) => void;
placeholder?: string;
unit?: string;
}
const InputGroup: React.FC<InputGroupProps> = ({ label, value, onChange, placeholder, unit }) => (
<div className="space-y-1">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em]">{label}</label>
<div className="relative">
<input
type="number"
className="w-full bg-surface-soft border border-border/40 rounded-xl px-4 py-2.5 text-text-main font-bold focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all placeholder:text-text-muted/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{unit && <span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs font-bold text-text-muted">{unit}</span>}
</div>
</div>
);
export const CalculatorDashboard: React.FC = () => {
const [activeTab, setActiveTab] = useState('conversion');
// 1. Conversion
const [microns, setMicrons] = useState<string>('');
const [mils, setMils] = useState<string>('');
const handleMicronChange = (val: string) => {
setMicrons(val);
if (val) setMils((parseFloat(val) / 25.4).toFixed(2));
else setMils('');
};
const handleMilsChange = (val: string) => {
setMils(val);
if (val) setMicrons((parseFloat(val) * 25.4).toFixed(1));
else setMicrons('');
};
// 2. Thickness
const [epsParams, setEpsParams] = useState({ sv: '', dilution: '', wft: '' });
const epsResult = useMemo(() => {
const { sv, dilution, wft } = epsParams;
if (sv && wft) {
const svVal = parseFloat(sv);
const dilVal = parseFloat(dilution) || 0;
const wftVal = parseFloat(wft);
return (wftVal * svVal) / (100 + dilVal);
}
return null;
}, [epsParams]);
const [epuParams, setEpuParams] = useState({ sv: '', dilution: '', dft: '' });
const epuResult = useMemo(() => {
const { sv, dilution, dft } = epuParams;
if (sv && dft) {
const svVal = parseFloat(sv);
const dilVal = parseFloat(dilution) || 0;
const dftVal = parseFloat(dft);
return (dftVal * (100 + dilVal)) / svVal;
}
return null;
}, [epuParams]);
const [deadVolParams, setDeadVolParams] = useState({ roughness: '', sv: '', area: '' });
const deadVolResult = useMemo(() => {
const { roughness, sv, area } = deadVolParams;
if (roughness && sv && area) {
const rVal = parseFloat(roughness);
const svVal = parseFloat(sv);
const aVal = parseFloat(area);
const volDry = (aVal * rVal * 0.5) / 1000;
const volWet = volDry / (svVal / 100);
return { dry: volDry, wet: volWet };
}
return null;
}, [deadVolParams]);
// 3. Dew Point
const [envParams, setEnvParams] = useState({ temp: '', rh: '' });
const { dewPoint, dpStatus } = useMemo(() => {
const { temp, rh } = envParams;
if (temp && rh) {
const T = parseFloat(temp);
const RH = parseFloat(rh);
const a = 17.27;
const b = 237.7;
const alpha = ((a * T) / (b + T)) + Math.log(RH / 100);
const Td = (b * alpha) / (a - alpha);
const delta = T - Td;
let status = '';
if (delta < 3) status = 'Risco: Condensação iminente (Delta < 3°C)';
else status = 'Condição Segura (Delta > 3°C)';
return { dewPoint: Td, dpStatus: status };
}
return { dewPoint: null, dpStatus: '' };
}, [envParams]);
// 4. Consumption & Cost
const [consAreaParams, setConsAreaParams] = useState({ area: '', eps: '', sv: '', loss: '' });
const consAreaResult = useMemo(() => {
const { area, eps, sv, loss } = consAreaParams;
if (area && eps && sv) {
const a = parseFloat(area);
const e = parseFloat(eps);
const s = parseFloat(sv);
const l = parseFloat(loss) || 0;
const theo = (a * e) / (10 * s);
return theo / (1 - (l / 100));
}
return null;
}, [consAreaParams]);
const [consWeightParams, setConsWeightParams] = useState({ weight: '', relation: '' });
const consWeightResult = useMemo(() => {
const { weight, relation } = consWeightParams;
if (weight && relation) {
const w = parseFloat(weight);
const r = parseFloat(relation);
const tons = w / 1000;
return tons * r;
}
return null;
}, [consWeightParams]);
const [costParams, setCostParams] = useState({ coats: '1', area: '', eps: '', sv: '', dilution: '0', loss: '', price: '' });
const costResult = useMemo(() => {
const { coats, area, eps, sv, dilution, loss, price } = costParams;
if (area && eps && sv && price) {
const c = parseFloat(coats) || 1;
const a = parseFloat(area);
const e = parseFloat(eps);
const s = parseFloat(sv);
const d = parseFloat(dilution) || 0;
const l = parseFloat(loss) || 0;
const p = parseFloat(price);
const theoPaint = (a * e) / (10 * s);
const realPaintPerCoat = theoPaint / (1 - (l / 100));
const totalPaint = realPaintPerCoat * c;
const totalThinner = totalPaint * (d / 100);
const totalCost = totalPaint * p;
return { totalCost, totalPaint, totalThinner };
}
return null;
}, [costParams]);
// 5. Nozzles
const [nozzleCode, setNozzleCode] = useState('');
const [showNozzleHelp, setShowNozzleHelp] = useState(false);
const [nozzleResult, setNozzleResult] = useState<{ fan: number, flow: number, desc: string } | null>(null);
const calculateNozzle = () => {
if (nozzleCode.length < 3) return;
const widthDigit = parseInt(nozzleCode[0]);
const holeDigits = parseInt(nozzleCode.slice(1));
const fan = widthDigit * 5;
const flow = (holeDigits * holeDigits) * 0.0039;
let desc = '';
if (holeDigits < 15) desc = "Acabamento fino, líquidos leves (Verniz, Stain)";
else if (holeDigits < 19) desc = "Uso geral, média viscosidade (Látex, Esmalte)";
else if (holeDigits < 25) desc = "Alta produtividade, viscosidade alta (Epóxi, PU)";
else desc = "Extrema cobertura, materiais pesados (Massa, Ignífugo)";
setNozzleResult({ fan, flow, desc });
};
return (
<div className="space-y-8 animate-in fade-in duration-700">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-4">
<div>
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">Ferramentas & Cálculos</h1>
<p className="text-sm text-text-muted font-medium tracking-wide">Utilitários técnicos para pintura industrial</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto">
<nav className="flex space-x-10 min-w-max px-2" aria-label="Tabs">
{[
{ id: 'conversion', label: 'Conversões', icon: ArrowRightLeft },
{ id: 'thickness', label: 'Espessura', icon: Ruler },
{ id: 'dewpoint', label: 'Ambiente', icon: CloudRain },
{ id: 'consumption', label: 'Consumo & Custo', icon: PiggyBank },
{ id: 'nozzles', label: 'Bicos Airless', icon: Paintbrush },
].map((tab) => {
const Icon = tab.icon;
const active = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'whitespace-nowrap py-5 px-1 border-b-2 font-bold text-xs uppercase tracking-[0.15em] flex items-center gap-3 transition-all relative',
active
? 'border-primary text-primary'
: 'border-transparent text-text-muted hover:text-text-main hover:border-border'
)}
>
<Icon className={clsx("w-4 h-4", active ? "text-primary" : "text-text-muted")} />
{tab.label}
{active && <span className="absolute bottom-[-1px] left-0 right-0 h-[2px] bg-primary shadow-[0_0_12px_rgba(13,127,242,0.6)]"></span>}
</button>
);
})}
</nav>
</div>
{/* Content for Tabs */}
<div className="min-h-[400px]">
{/* 1. CONVERSION */}
{activeTab === 'conversion' && (
<div className="max-w-2xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<Card className="p-8 space-y-8 bg-surface border-border/40 shadow-soft">
<div className="flex flex-col gap-2 border-b border-border/40 pb-6">
<h3 className="text-xl font-bold text-text-main">Conversor de Unidades</h3>
<p className="text-sm text-text-muted">Microns (μm) Milesimos de Polegada (mils)</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
<InputGroup
label={<>Microns (<span className="normal-case">μm</span>)</>}
unit="μm"
value={microns}
onChange={handleMicronChange}
placeholder="0"
/>
<div className="hidden md:flex justify-center text-text-muted"><ArrowRightLeft /></div>
<InputGroup label="Mils" unit="mils" value={mils} onChange={handleMilsChange} placeholder="0" />
</div>
<div className="bg-primary/5 rounded-xl p-4 flex gap-3 text-sm text-text-secondary">
<Info className="w-5 h-5 text-primary shrink-0" />
<p>Fator de conversão: <span className="font-bold text-text-main">1 mil = 25.4 μm</span>.</p>
</div>
</Card>
</div>
)}
{/* 2. THICKNESS */}
{activeTab === 'thickness' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 animate-in slide-in-from-bottom-4 duration-500">
{/* EPS Calc */}
<Card className="p-6 space-y-6 border-border/40 hover:shadow-md transition-all">
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-500"><Ruler size={18} /></div>
<h3 className="font-bold text-text-main">Cálculo de EPS (Seca)</h3>
</div>
<div className="space-y-4">
<InputGroup label="Espessura Úmida (EPU)" unit="μm" value={epsParams.wft} onChange={(v) => setEpsParams({ ...epsParams, wft: v })} />
<InputGroup label="Sólidos por Volume" unit="%" value={epsParams.sv} onChange={(v) => setEpsParams({ ...epsParams, sv: v })} />
<InputGroup label="Diluição Adicionada" unit="%" value={epsParams.dilution} onChange={(v) => setEpsParams({ ...epsParams, dilution: v })} />
</div>
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center">
<span className="text-[10px] uppercase font-bold text-text-muted">Resultado Esperado</span>
<div className="text-3xl font-black text-primary mt-1">{epsResult ? epsResult.toFixed(1) : '--'} <span className="text-sm text-text-muted">μm</span></div>
</div>
</Card>
{/* EPU Calc */}
<Card className="p-6 space-y-6 border-border/40 hover:shadow-md transition-all">
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-500"><Droplets size={18} /></div>
<h3 className="font-bold text-text-main">Cálculo de EPU (Úmida)</h3>
</div>
<div className="space-y-4">
<InputGroup label="EPS Desejada" unit="μm" value={epuParams.dft} onChange={(v) => setEpuParams({ ...epuParams, dft: v })} />
<InputGroup label="Sólidos por Volume" unit="%" value={epuParams.sv} onChange={(v) => setEpuParams({ ...epuParams, sv: v })} />
<InputGroup label="Diluição Prevista" unit="%" value={epuParams.dilution} onChange={(v) => setEpuParams({ ...epuParams, dilution: v })} />
</div>
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center">
<span className="text-[10px] uppercase font-bold text-text-muted">Aplicar Camada de</span>
<div className="text-3xl font-black text-indigo-500 mt-1">{epuResult ? epuResult.toFixed(0) : '--'} <span className="text-sm text-text-muted">μm</span></div>
</div>
</Card>
{/* Dead Volume */}
<Card className="p-6 space-y-6 border-border/40 hover:shadow-md transition-all">
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<div className="p-2 bg-amber-500/10 rounded-lg text-amber-500"><Layers size={18} /></div>
<h3 className="font-bold text-text-main">Volume Morto</h3>
</div>
<div className="space-y-4">
<InputGroup label="Área Total" unit="m²" value={deadVolParams.area} onChange={(v) => setDeadVolParams({ ...deadVolParams, area: v })} />
<InputGroup label="Rugosidade Média" unit="μm" value={deadVolParams.roughness} onChange={(v) => setDeadVolParams({ ...deadVolParams, roughness: v })} />
<InputGroup label="Sólidos por Volume" unit="%" value={deadVolParams.sv} onChange={(v) => setDeadVolParams({ ...deadVolParams, sv: v })} />
</div>
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center space-y-2">
<div>
<span className="text-[9px] uppercase font-bold text-text-muted">Volume Tinta (L)</span>
<div className="text-2xl font-black text-text-main mt-0.5">{deadVolResult ? deadVolResult.wet.toFixed(2) : '--'} <span className="text-xs text-text-muted">L</span></div>
</div>
</div>
</Card>
</div>
)}
{/* 3. DEW POINT */}
{activeTab === 'dewpoint' && (
<div className="max-w-xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<Card className="p-8 space-y-8 bg-surface border-border/40 shadow-soft">
<div className="flex flex-col gap-2 border-b border-border/40 pb-6">
<h3 className="text-xl font-bold text-text-main">Ponto de Orvalho</h3>
<p className="text-sm text-text-muted">Cálculo da temperatura de condensação</p>
</div>
<div className="grid grid-cols-2 gap-6">
<InputGroup label="Temp. Ambiente" unit="°C" value={envParams.temp} onChange={(v) => setEnvParams({ ...envParams, temp: v })} />
<InputGroup label="Umidade Relativa" unit="%" value={envParams.rh} onChange={(v) => setEnvParams({ ...envParams, rh: v })} />
</div>
<div className={clsx(
"rounded-xl p-6 text-center transition-all border",
dewPoint === null ? "bg-surface-soft border-transparent" :
(dpStatus.includes('Risco') ? "bg-error/10 border-error/30" : "bg-success/10 border-success/30")
)}>
<span className="text-[10px] uppercase font-bold text-text-muted block mb-2">Ponto de Orvalho Calculado</span>
<div className={clsx("text-4xl font-black mb-2", dpStatus.includes('Risco') ? "text-error" : "text-success")}>
{dewPoint ? dewPoint.toFixed(1) : '--'}°C
</div>
<span className={clsx("text-xs font-bold uppercase tracking-widest", dpStatus.includes('Risco') ? "text-error" : "text-success")}>
{dpStatus || 'Aguardando dados...'}
</span>
</div>
</Card>
</div>
)}
{/* 4. CONSUMPTION & COST */}
{activeTab === 'consumption' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 animate-in slide-in-from-bottom-4 duration-500">
{/* Area Consumption */}
<Card className="p-6 space-y-6 border-border/40">
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<div className="p-2 bg-emerald-500/10 rounded-lg text-emerald-500"><Paintbrush size={18} /></div>
<h3 className="font-bold text-text-main">Consumo por Área</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<InputGroup label="Área" unit="m²" value={consAreaParams.area} onChange={(v) => setConsAreaParams({ ...consAreaParams, area: v })} />
<InputGroup label="EPS" unit="μm" value={consAreaParams.eps} onChange={(v) => setConsAreaParams({ ...consAreaParams, eps: v })} />
<InputGroup label="Sólidos Vol." unit="%" value={consAreaParams.sv} onChange={(v) => setConsAreaParams({ ...consAreaParams, sv: v })} />
<InputGroup label="Perdas" unit="%" value={consAreaParams.loss} onChange={(v) => setConsAreaParams({ ...consAreaParams, loss: v })} />
</div>
<div className="pt-4 mt-4 bg-surface-soft rounded-xl p-4 text-center">
<span className="text-[10px] uppercase font-bold text-text-muted">Consumo Estimado</span>
<div className="text-3xl font-black text-emerald-500 mt-1">{consAreaResult ? consAreaResult.toFixed(1) : '--'} <span className="text-sm text-text-muted">Litros</span></div>
</div>
</Card>
{/* Weight Consumption */}
<Card className="p-6 space-y-6 border-border/40">
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<div className="p-2 bg-orange-500/10 rounded-lg text-orange-500"><PiggyBank size={18} /></div>
<h3 className="font-bold text-text-main">Consumo por Peso</h3>
</div>
<div className="space-y-4">
<InputGroup label="Peso Total" unit="Kg" value={consWeightParams.weight} onChange={(v) => setConsWeightParams({ ...consWeightParams, weight: v })} />
<InputGroup label="Relação" unit="L/Ton" value={consWeightParams.relation} onChange={(v) => setConsWeightParams({ ...consWeightParams, relation: v })} />
</div>
<div className="pt-4 mt-8 bg-surface-soft rounded-xl p-4 text-center">
<span className="text-[10px] uppercase font-bold text-text-muted">Volume Necessário</span>
<div className="text-3xl font-black text-orange-500 mt-1">{consWeightResult ? consWeightResult.toFixed(1) : '--'} <span className="text-sm text-text-muted">Litros</span></div>
</div>
</Card>
{/* Cost Estimator */}
<Card className="p-6 space-y-6 border-border/40 lg:col-span-2">
<div className="flex items-center gap-3 pb-4 border-b border-border/40">
<div className="p-2 bg-primary/10 rounded-lg text-primary"><Calculator size={18} /></div>
<h3 className="font-bold text-text-main">Estimativa de Custos</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InputGroup label="Demãos" unit="un" value={costParams.coats} onChange={(v) => setCostParams({ ...costParams, coats: v })} />
<InputGroup label="Área" unit="m²" value={costParams.area} onChange={(v) => setCostParams({ ...costParams, area: v })} />
<InputGroup label="EPS" unit="μm" value={costParams.eps} onChange={(v) => setCostParams({ ...costParams, eps: v })} />
<InputGroup label="Preço" unit="R$/L" value={costParams.price} onChange={(v) => setCostParams({ ...costParams, price: v })} />
<InputGroup label="Sólidos Vol." unit="%" value={costParams.sv} onChange={(v) => setCostParams({ ...costParams, sv: v })} />
<InputGroup label="Perdas" unit="%" value={costParams.loss} onChange={(v) => setCostParams({ ...costParams, loss: v })} />
<InputGroup label="Diluição" unit="%" value={costParams.dilution} onChange={(v) => setCostParams({ ...costParams, dilution: v })} />
</div>
<div className="pt-4 mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-surface-soft rounded-xl p-4 text-center">
<span className="text-[9px] uppercase font-bold text-text-muted">Volume Tinta</span>
<div className="text-xl font-black text-text-main">{costResult ? costResult.totalPaint.toFixed(1) : '--'} L</div>
</div>
<div className="bg-surface-soft rounded-xl p-4 text-center">
<span className="text-[9px] uppercase font-bold text-text-muted">Volume Diluente</span>
<div className="text-xl font-black text-text-main">{costResult ? costResult.totalThinner.toFixed(1) : '--'} L</div>
</div>
<div className="bg-primary/10 rounded-xl p-4 text-center border border-primary/20">
<span className="text-[9px] uppercase font-bold text-text-muted">Custo Estimado</span>
<div className="text-2xl font-black text-primary">R$ {costResult ? costResult.totalCost.toFixed(2) : '--'}</div>
</div>
</div>
</Card>
</div>
)}
{/* 5. Nozzles */}
{activeTab === 'nozzles' && (
<div className="max-w-xl mx-auto space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<Card className="p-8 space-y-8 bg-surface border-border/40 shadow-soft">
<div className="flex flex-col gap-2 border-b border-border/40 pb-6">
<h3 className="text-xl font-bold text-text-main">Seletor de Bicos Airless</h3>
<p className="text-sm text-text-muted">Insira o código do bico (ex: 517) para ver detalhes</p>
</div>
<div className="flex gap-4 items-end">
<div className="flex-1">
<InputGroup label="Código do Bico" placeholder="ex: 517" value={nozzleCode} onChange={setNozzleCode} />
</div>
<button
onClick={() => setShowNozzleHelp(!showNozzleHelp)}
className="mb-[2px] p-3 rounded-xl bg-surface-soft text-text-muted hover:text-primary hover:bg-primary/10 transition-colors"
title="Como ler o código?"
>
<HelpCircle size={20} />
</button>
<Button onClick={calculateNozzle} className="mb-[2px]">Calcular</Button>
</div>
{showNozzleHelp && (
<div className="bg-surface-soft border border-border/40 rounded-xl p-4 text-sm space-y-3 animate-in fade-in zoom-in-95 duration-200">
<h4 className="font-bold text-text-main flex items-center gap-2">
<Info size={16} className="text-primary" />
Entendendo o Código (ex: 517)
</h4>
<ul className="space-y-2 text-text-secondary">
<li className="flex gap-2">
<span className="font-black text-primary shrink-0">1º Dígito</span>
<div>
<strong className="text-text-main">Ângulo do Leque:</strong> Multiplique por 5 para saber a largura em cm (aprox a 30cm da superfície).
<br /><span className="text-xs text-text-muted">Ex: 5xx = 50° (aprox. 25cm).</span>
</div>
</li>
<li className="flex gap-2">
<span className="font-black text-primary shrink-0">Últimos</span>
<div>
<strong className="text-text-main">Orifício (Vazão):</strong> Diâmetro em milésimos de polegada. Quanto maior, mais tinta sai.
<br /><span className="text-xs text-text-muted">Ex: x17 = 0.017". Indicado para látex/esmalte.</span>
</div>
</li>
</ul>
</div>
)}
{nozzleResult && (
<div className="bg-surface-soft border border-border/20 rounded-2xl p-6 space-y-6">
<div className="grid grid-cols-2 gap-8 text-center">
<div>
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block mb-1">Abertura Leque</span>
<span className="text-3xl font-black text-text-main">{nozzleResult.fan} <span className="text-sm text-text-muted font-bold">cm</span></span>
<p className="text-[10px] text-text-muted mt-1">(Aprox. a 30cm)</p>
</div>
<div>
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block mb-1">Vazão Aprox.</span>
<span className="text-3xl font-black text-primary">{nozzleResult.flow.toFixed(2)} <span className="text-sm text-text-muted font-bold">L/min</span></span>
<p className="text-[10px] text-text-muted mt-1">(@ 2000 psi)</p>
</div>
</div>
<div className="pt-4 border-t border-border/20">
<p className="text-center text-sm font-medium text-text-secondary">{nozzleResult.desc}</p>
</div>
</div>
)}
<div className="bg-primary/5 rounded-xl p-4 flex gap-3 text-sm text-text-secondary">
<Info className="w-5 h-5 text-primary shrink-0" />
<p className="text-xs">Valores teóricos de referência. Consulte sempre a ficha técnica do fabricante do equipamento.</p>
</div>
</Card>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,694 @@
import React, { useEffect, useState } from 'react';
import { Button } from '../components/Button';
import { Card } from '../components/Card';
import { Search, Plus, Trash2, FileText, Download, X, Loader2, Edit2 } from 'lucide-react';
import * as dataSheetService from '../services/dataSheetService';
import type { TechnicalDataSheet } from '../types';
import { format } from 'date-fns';
import { useAuth } from '../context/useAuth';
import { useToast } from '../hooks/useToast';
export const DataSheetLibrary: React.FC = () => {
const { isGuest, isAdmin } = useAuth();
const { showGuestWarning } = useToast();
const [sheets, setSheets] = useState<TechnicalDataSheet[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Form State
const [formData, setFormData] = useState({
name: '',
manufacturer: '',
type: '',
solidsVolume: '',
yieldTheoretical: '',
dftReference: '',
yieldFactor: '',
wftMin: '',
wftMax: '',
dftMin: '',
dftMax: '',
reducer: '',
mixingRatioWeight: '',
mixingRatioVolume: '',
dilution: '',
notes: '',
fileUrl: ''
});
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [extracting, setExtracting] = useState(false);
useEffect(() => {
fetchSheets();
}, []);
const fetchSheets = async () => {
try {
const response = await dataSheetService.getDataSheets();
setSheets(response.data);
} catch (error) {
console.error('Error fetching sheets', error);
} finally {
setLoading(false);
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setSelectedFile(file);
// Auto-extract only for new uploads
setExtracting(true);
try {
const response = await dataSheetService.extractDataSheet(file);
const data = response.data;
setFormData(prev => ({
...prev,
name: prev.name || data.name || '',
manufacturer: prev.manufacturer || data.manufacturer || '',
type: prev.type || data.type || '',
solidsVolume: data.solidsVolume ? String(data.solidsVolume) : prev.solidsVolume,
yieldTheoretical: data.yieldTheoretical ? String(data.yieldTheoretical) : prev.yieldTheoretical,
dftReference: data.dftReference ? String(data.dftReference) : prev.dftReference,
yieldFactor: data.yieldFactor ? String(data.yieldFactor) : prev.yieldFactor,
wftMin: data.wftMin ? String(data.wftMin) : prev.wftMin,
wftMax: data.wftMax ? String(data.wftMax) : prev.wftMax,
dftMin: data.dftMin ? String(data.dftMin) : prev.dftMin,
dftMax: data.dftMax ? String(data.dftMax) : prev.dftMax,
reducer: data.reducer || prev.reducer,
mixingRatioWeight: data.mixingRatioWeight || prev.mixingRatioWeight,
mixingRatioVolume: data.mixingRatioVolume || prev.mixingRatioVolume,
fileUrl: data.tempFilePath // Store temporary path
}));
} catch (err) {
console.error('Extraction failed', err);
} finally {
setExtracting(false);
}
}
};
const handleEdit = (sheet: TechnicalDataSheet) => {
setEditingId(sheet.id);
setFormData({
name: sheet.name,
manufacturer: sheet.manufacturer || '',
type: sheet.type || '',
solidsVolume: sheet.solidsVolume ? String(sheet.solidsVolume) : '',
yieldTheoretical: sheet.yieldTheoretical ? String(sheet.yieldTheoretical) : '',
dftReference: sheet.dftReference ? String(sheet.dftReference) : '',
yieldFactor: sheet.yieldFactor ? String(sheet.yieldFactor) : '',
wftMin: sheet.wftMin ? String(sheet.wftMin) : '',
wftMax: sheet.wftMax ? String(sheet.wftMax) : '',
dftMin: sheet.dftMin ? String(sheet.dftMin) : '',
dftMax: sheet.dftMax ? String(sheet.dftMax) : '',
reducer: sheet.reducer || '',
mixingRatioWeight: sheet.mixingRatioWeight || '',
mixingRatioVolume: sheet.mixingRatioVolume || '',
dilution: sheet.dilution ? String(sheet.dilution) : '',
notes: sheet.notes || '',
fileUrl: sheet.fileUrl || ''
});
setSelectedFile(null); // Reset file selection unless user uploads new one
setIsModalOpen(true);
};
const handleOpenModal = () => {
setEditingId(null);
setFormData({
name: '', manufacturer: '', type: '', solidsVolume: '', yieldTheoretical: '',
dftReference: '', yieldFactor: '', wftMin: '', wftMax: '', dftMin: '', dftMax: '',
reducer: '', mixingRatioWeight: '', mixingRatioVolume: '', notes: '', fileUrl: '',
dilution: ''
});
setSelectedFile(null);
setIsModalOpen(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
if (!formData.name) return;
setUploading(true);
const data = new FormData();
if (selectedFile) {
data.append('file', selectedFile);
} else if (formData.fileUrl) {
data.append('fileUrl', formData.fileUrl);
}
data.append('name', formData.name);
data.append('manufacturer', formData.manufacturer);
data.append('type', formData.type);
data.append('solidsVolume', formData.solidsVolume);
data.append('yieldTheoretical', formData.yieldTheoretical);
data.append('dftReference', formData.dftReference);
data.append('yieldFactor', formData.yieldFactor);
data.append('wftMin', formData.wftMin);
data.append('wftMax', formData.wftMax);
data.append('dftMin', formData.dftMin);
data.append('dftMax', formData.dftMax);
data.append('reducer', formData.reducer);
data.append('mixingRatioWeight', formData.mixingRatioWeight);
data.append('mixingRatioVolume', formData.mixingRatioVolume);
data.append('dilution', formData.dilution);
data.append('notes', formData.notes);
try {
if (editingId) {
await dataSheetService.updateDataSheet(editingId, data);
} else {
if (!selectedFile && !formData.fileUrl) {
alert('Selecione um arquivo.');
setUploading(false);
return;
}
await dataSheetService.createDataSheet(data);
}
setIsModalOpen(false);
fetchSheets();
} catch (error) {
console.error('Error saving sheet', error);
alert('Erro ao salvar ficha técnica.');
} finally {
setUploading(false);
}
};
const handleDelete = async (id: string) => {
if (isGuest()) {
showGuestWarning();
return;
}
if (!confirm('Tem certeza que deseja excluir esta ficha?')) return;
try {
await dataSheetService.deleteDataSheet(id);
fetchSheets();
} catch (error) {
console.error('Error deleting sheet', error);
}
};
const updateTechnicalData = (field: string, value: string) => {
const newData = { ...formData, [field]: value };
// Convert to numbers for calculation
const solids = field === 'solidsVolume' ? parseFloat(value) : parseFloat(newData.solidsVolume);
const yieldT = field === 'yieldTheoretical' ? parseFloat(value) : parseFloat(newData.yieldTheoretical);
const dft = field === 'dftReference' ? parseFloat(value) : parseFloat(newData.dftReference);
const factor = field === 'yieldFactor' ? parseFloat(value) : parseFloat(newData.yieldFactor);
if (field === 'solidsVolume' && !isNaN(solids)) {
// Formula: Factor = Solids * 10
newData.yieldFactor = (solids * 10).toFixed(2);
// If we have DFT, update Yield: Yield = Factor / DFT
if (!isNaN(dft) && dft !== 0) {
newData.yieldTheoretical = (solids * 10 / dft).toFixed(2);
}
} else if (field === 'yieldTheoretical' && !isNaN(yieldT)) {
// If we have DFT, update Factor: Factor = Yield * DFT
if (!isNaN(dft)) {
newData.yieldFactor = (yieldT * dft).toFixed(2);
newData.solidsVolume = (yieldT * dft / 10).toFixed(1);
}
} else if (field === 'dftReference' && !isNaN(dft)) {
// If we have Factor, update Yield: Yield = Factor / DFT
if (!isNaN(factor) && dft !== 0) {
newData.yieldTheoretical = (factor / dft).toFixed(2);
} else if (!isNaN(solids) && dft !== 0) {
newData.yieldTheoretical = (solids * 10 / dft).toFixed(2);
}
} else if (field === 'yieldFactor' && !isNaN(factor)) {
newData.solidsVolume = (factor / 10).toFixed(1);
if (!isNaN(dft) && dft !== 0) {
newData.yieldTheoretical = (factor / dft).toFixed(2);
}
}
setFormData(newData);
};
const filteredSheets = sheets.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.manufacturer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.type?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
<FileText className="w-8 h-8" />
</div>
<div>
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Biblioteca Técnica</h1>
<p className="text-sm text-text-muted font-medium tracking-widest uppercase">Repositório de Fichas de Produto</p>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="relative hidden lg:block group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar tintas ou fabricantes..."
className="h-14 w-80 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{isAdmin() && (
<Button onClick={handleOpenModal} size="lg" className="shadow-primary/30 h-14">
<Plus className="w-5 h-5 mr-2" />
Nova Ficha
</Button>
)}
</div>
</div>
{/* Mobile/Small Screen Search */}
<div className="relative lg:hidden">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
placeholder="Buscar..."
className="h-12 w-full bg-surface border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-32 space-y-4">
<Loader2 size={40} className="text-primary animate-spin" />
<p className="text-sm font-bold text-text-muted uppercase tracking-[0.2em]">Carregando Biblioteca...</p>
</div>
) : filteredSheets.length === 0 ? (
<div className="text-center py-32 bg-surface/50 rounded-[40px] border border-dashed border-border/40">
<div className="mx-auto h-24 w-24 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/10 mb-8 border border-border/20">
<FileText className="w-12 h-12" />
</div>
<h3 className="text-2xl font-black text-text-main tracking-tight">Nenhuma ficha disponível</h3>
<p className="mt-2 text-text-muted font-medium max-w-sm mx-auto">Sua biblioteca de produtos está vazia. Comece carregando um PDF técnico.</p>
{isAdmin() && (
<Button
variant="secondary"
size="md"
className="mt-8 border-border/50"
onClick={handleOpenModal}
>
<Plus className="w-4 h-4 mr-2" /> Cadastrar Produto
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-8">
{filteredSheets.map((sheet) => (
<div key={sheet.id} className="group relative">
{/* Card Background Bloom Effect */}
<div className="absolute -inset-1 bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 rounded-[32px] blur-xl opacity-0 group-hover:opacity-100 transition-all duration-700"></div>
<Card className="p-0 flex flex-col h-full relative overflow-hidden bg-surface border border-border/40 rounded-[32px] group-hover:border-primary/40 transition-all duration-500 shadow-soft group-hover:shadow-2xl">
{/* Header Section */}
<div className="p-8 pb-4 relative">
<div className="flex justify-between items-start mb-6">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-black text-primary uppercase tracking-[0.25em]">{sheet.manufacturer || 'FABRICANTE N/D'}</span>
<h3 className="text-2xl font-black text-text-main tracking-tighter leading-none group-hover:text-primary transition-colors" title={sheet.name}>
{sheet.name}
</h3>
</div>
{isAdmin() && (
<div className="flex gap-1 p-1 bg-surface-soft/80 backdrop-blur-md rounded-2xl border border-border/40 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all">
<button
onClick={() => handleEdit(sheet)}
className="p-2.5 text-text-muted hover:text-primary transition-all rounded-xl hover:bg-primary/5"
title="Editar Ficha"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(sheet.id)}
className="p-2.5 text-text-muted hover:text-error transition-all rounded-xl hover:bg-error/5"
title="Remover Ficha"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1 bg-primary/10 text-primary text-[9px] font-black uppercase tracking-widest rounded-full border border-primary/20">
{sheet.type || 'S/ TIPO'}
</span>
{sheet.reducer && (
<span className="px-3 py-1 bg-surface-highlight text-text-secondary text-[9px] font-black uppercase tracking-widest rounded-full border border-border/50">
DILUENTE: {sheet.reducer}
</span>
)}
</div>
</div>
{/* Body Section */}
<div className="px-8 space-y-5 flex-1">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Sólidos por Vol.</span>
<span className="text-xl font-black text-text-main">{sheet.solidsVolume || '0'}%</span>
</div>
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Rend. Teórico</span>
<span className="text-xl font-black text-text-main">{sheet.yieldTheoretical || '0'} <span className="text-[9px] text-text-muted">m²/L</span></span>
</div>
</div>
<div className="p-4 bg-primary/[0.03] border border-primary/10 rounded-2xl">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-black text-primary/70 uppercase tracking-widest flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></div>
Performance Ref.
</span>
<span className="text-xs font-black text-primary">{sheet.yieldFactor || '0.00'} <span className="text-[8px] font-bold">m².μm/L</span></span>
</div>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-text-muted uppercase">EPS de Referência</span>
<span className="text-sm font-bold text-text-main">{sheet.dftReference || '--'} <span className="text-[10px]">μm</span></span>
</div>
</div>
{sheet.mixingRatioVolume && (
<div className="py-1">
<div className="text-[10px] font-bold text-text-muted uppercase tracking-widest border-b border-border/40 pb-2 mb-3">Relação de Mistura (Vol.)</div>
<div className="flex items-center gap-6">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-black text-text-main">{sheet.mixingRatioVolume.split(':')[0] || '1'}</span>
<span className="text-[10px] font-bold text-text-muted">PART A</span>
</div>
<div className="h-4 w-[1px] bg-border/40"></div>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-black text-text-main">{sheet.mixingRatioVolume.split(':')[1] || '1'}</span>
<span className="text-[10px] font-bold text-text-muted">PART B</span>
</div>
</div>
</div>
)}
</div>
{/* Footer Section */}
<div className="p-8 pt-6 border-t border-border/40 mt-6 flex items-center justify-between">
<div className="flex flex-col">
<span className="text-[9px] font-black text-text-muted uppercase tracking-tighter">Última Atualização</span>
<span className="text-[11px] font-bold text-text-secondary">{format(new Date(sheet.uploadDate), 'dd MMM, yyyy')}</span>
</div>
{(() => {
if (!sheet.fileUrl) return null;
const fileUrl = sheet.fileUrl.startsWith('http')
? sheet.fileUrl
: `${dataSheetService.getBaseUrl()}/datasheets/file/${sheet.fileUrl}`;
return (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 h-10 px-6 bg-surface-highlight hover:bg-primary hover:text-white border border-border/40 rounded-xl text-xs font-black transition-all group/btn"
>
<Download size={14} className="group-hover/btn:-translate-y-0.5 transition-transform" />
PDF TÉCNICO
</a>
);
})()}
</div>
</Card>
</div>
))}
</div>
)}
{/* Modal - Reusable for Create and Edit */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
<div className="bg-surface rounded-2xl shadow-2xl w-full max-w-lg border border-border/50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-border flex justify-between items-center bg-surface">
<h2 className="text-xl font-bold text-text-main">
{editingId ? 'Editar Ficha Técnica' : 'Nova Ficha Técnica'}
</h2>
<button
onClick={() => setIsModalOpen(false)}
className="text-text-muted hover:text-text-main p-1 rounded-lg hover:bg-surface-soft transition-colors"
aria-label="Fechar"
>
<X size={20} />
</button>
</div>
<div className="p-6 overflow-y-auto custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-5">
<div className={`p-4 rounded-xl border border-dashed transition-all ${selectedFile ? 'bg-primary/5 border-primary' : 'bg-surface-soft border-border hover:border-primary/50'}`}>
<div className="flex items-center gap-4">
<div className={`p-3 rounded-lg ${extracting ? 'bg-amber-100 text-amber-600' : 'bg-white text-primary shadow-sm'}`}>
{extracting ? <Loader2 size={24} className="animate-spin" /> : <FileText size={24} />}
</div>
<div className="flex-1 min-w-0">
<label htmlFor="pdf-upload" className="block text-sm font-semibold text-text-main mb-1 cursor-pointer">
{extracting ? 'Analisando documento...' : (editingId ? 'Substituir PDF (Opcional)' : 'Carregar PDF')}
</label>
<input
id="pdf-upload"
type="file"
accept=".pdf"
onChange={handleFileSelect}
className="w-full text-xs text-text-secondary file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-primary file:text-white hover:file:bg-primary-dark cursor-pointer"
disabled={extracting}
/>
<p className="text-xs text-text-muted mt-1 truncate">
{extracting
? 'Utilizando OCR se necessário...'
: (editingId ? 'Mantenha vazio para usar o atual.' : 'Preenchemos os campos automaticamente para você.')}
</p>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Nome do Produto *</label>
<input
id="product-name"
required
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: Rezinc Wand 500"
title="Nome do Produto"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Fabricante</label>
<input
id="manufacturer"
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.manufacturer}
onChange={e => setFormData({ ...formData, manufacturer: e.target.value })}
placeholder="Ex: WEG"
title="Fabricante"
/>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Redutor (Diluente)</label>
<input
id="reducer"
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.reducer}
onChange={e => setFormData({ ...formData, reducer: e.target.value })}
placeholder="Ex: 420.0000"
title="Redutor (Diluente)"
/>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Tipo de Tinta</label>
<input
id="paint-type"
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })}
placeholder="Ex: Epóxi"
title="Tipo de Tinta"
/>
</div>
</div>
<div className="border-t border-border pt-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-bold text-primary uppercase tracking-wider bg-primary/10 px-2 py-0.5 rounded">Relação de Mistura</span>
<div className="h-px bg-border flex-1"></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Em Peso (Comp A : B)</label>
<input
id="mixing-ratio-weight"
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.mixingRatioWeight}
onChange={e => setFormData({ ...formData, mixingRatioWeight: e.target.value })}
placeholder="100 : 101"
title="Relação de Mistura em Peso"
/>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Em Volume (Comp A : B)</label>
<input
id="mixing-ratio-volume"
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.mixingRatioVolume}
onChange={e => setFormData({ ...formData, mixingRatioVolume: e.target.value })}
placeholder="1 : 1"
title="Relação de Mistura em Volume"
/>
</div>
</div>
</div>
<div className="border-t border-border pt-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-bold text-primary uppercase tracking-wider bg-primary/10 px-2 py-0.5 rounded">Dados Técnicos</span>
<div className="h-px bg-border flex-1"></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 grid grid-cols-2 gap-4 p-3 bg-surface-soft rounded-xl border border-border/50">
<div className="col-span-2 text-[10px] font-bold text-text-muted uppercase mb-1">Intervalos de Aplicação</div>
<div>
<label className="block text-[10px] font-medium text-text-secondary mb-1">Úmida (WFT µm)</label>
<div className="flex items-center gap-1">
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.wftMin} onChange={e => setFormData({ ...formData, wftMin: e.target.value })} placeholder="Min" />
<span className="text-xs text-text-muted">/</span>
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.wftMax} onChange={e => setFormData({ ...formData, wftMax: e.target.value })} placeholder="Max" />
</div>
</div>
<div>
<label className="block text-[10px] font-medium text-text-secondary mb-1">Seca (DFT µm)</label>
<div className="flex items-center gap-1">
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.dftMin} onChange={e => setFormData({ ...formData, dftMin: e.target.value })} placeholder="Min" />
<span className="text-xs text-text-muted">/</span>
<input type="number" className="w-full p-1.5 text-xs rounded border border-border bg-surface" value={formData.dftMax} onChange={e => setFormData({ ...formData, dftMax: e.target.value })} placeholder="Max" />
</div>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Vol. Sólidos (%)</label>
<div className="relative">
<input
id="solids-volume"
type="number" step="0.1"
className="w-full p-2.5 pl-3 pr-8 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.solidsVolume}
onChange={e => updateTechnicalData('solidsVolume', e.target.value)}
placeholder="0"
title="Volume de Sólidos (%)"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">%</span>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Espessura Ref. (DFT)</label>
<div className="relative">
<input
id="dft-reference"
type="number" step="1"
className="w-full p-2.5 pl-3 pr-10 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.dftReference}
onChange={e => updateTechnicalData('dftReference', e.target.value)}
placeholder="0"
title="Espessura Referência (DFT)"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">µm</span>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Rendimento Teórico</label>
<div className="relative">
<input
id="yield-theoretical"
type="number" step="0.01"
className="w-full p-2.5 pl-3 pr-10 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.yieldTheoretical}
onChange={e => updateTechnicalData('yieldTheoretical', e.target.value)}
placeholder="0.00"
title="Rendimento Teórico"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">m²/L</span>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Fator de Rendimento</label>
<div className="relative">
<input
id="yield-factor"
type="number" step="0.1"
className="w-full p-2.5 pl-3 pr-16 rounded-lg border-2 border-primary/30 bg-primary/5 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-bold"
value={formData.yieldFactor}
onChange={e => updateTechnicalData('yieldFactor', e.target.value)}
placeholder="0.0"
title="Fator de Rendimento"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-primary/70 text-[10px] font-bold leading-tight text-right w-12">m².µm/L</span>
</div>
</div>
<div>
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Diluição Recomendada (%)</label>
<div className="relative">
<input
id="dilution"
type="number"
className="w-full p-2.5 rounded-lg border border-border bg-surface focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
value={formData.dilution}
onChange={e => setFormData({ ...formData, dilution: e.target.value })}
placeholder="0"
title="Diluição Recomendada (%)"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">%</span>
</div>
</div>
</div>
<p className="mt-2 text-[10px] text-text-muted italic">
* O Fator de Rendimento representa a área coberta por 1L para 1µm de espessura (Sólidos % × 10).
</p>
</div>
<div className="pt-6 flex justify-end gap-3 border-t border-border mt-2">
<Button type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</Button>
<Button type="submit" disabled={uploading}>
{uploading ? (editingId ? 'Salvando...' : 'Enviando...') : (editingId ? 'Salvar Alterações' : 'Criar Ficha')}
</Button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,551 @@
import React, { useState, useEffect, useRef } from 'react';
import { clsx } from 'clsx';
import {
Users,
Building2,
Image as ImageIcon,
LayoutDashboard,
Globe,
UploadCloud,
X,
Terminal
} from 'lucide-react';
import { useSystemSettings } from '../context/SystemSettingsContext';
import { useToast } from '../context/ToastContext';
import { systemSettingsService } from '../services/systemSettingsService';
import type { GlobalUser, GlobalOrganization } from '../services/systemSettingsService';
export const DeveloperDashboard: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'organizations' | 'users' | 'branding'>('overview');
const { settings, updateSettings, isLoading } = useSystemSettings();
const { showToast } = useToast();
const [appName, setAppName] = useState('');
const [appSubtitle, setAppSubtitle] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [globalUsers, setGlobalUsers] = useState<GlobalUser[]>([]);
const [globalOrgs, setGlobalOrgs] = useState<GlobalOrganization[]>([]);
const [isLoadingData, setIsLoadingData] = useState(false);
// Fetch data when tab changes
useEffect(() => {
const fetchData = async () => {
if (activeTab === 'users') {
setIsLoadingData(true);
try {
const data = await systemSettingsService.getGlobalUsers();
setGlobalUsers(data);
} catch (error) {
console.error(error);
showToast('Erro ao carregar usuários.', 'error');
} finally {
setIsLoadingData(false);
}
} else if (activeTab === 'organizations') {
setIsLoadingData(true);
try {
const data = await systemSettingsService.getGlobalOrganizations();
setGlobalOrgs(data);
} catch (error) {
console.error(error);
showToast('Erro ao carregar organizações.', 'error');
} finally {
setIsLoadingData(false);
}
}
};
fetchData();
}, [activeTab, showToast]);
// Load initial values
useEffect(() => {
if (settings) {
setAppName(settings.appName);
setAppSubtitle(settings.appSubtitle);
setLogoUrl(settings.appLogoUrl || '');
}
}, [settings]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validations
if (file.size > 2 * 1024 * 1024) { // 2MB
showToast('O logo deve ter no máximo 2MB.', 'error');
return;
}
setIsUploading(true);
try {
console.log('Iniciando upload...');
const uploadedUrl = await systemSettingsService.uploadLogo(file);
console.log('Upload concluído. URL recebida:', uploadedUrl);
if (uploadedUrl) {
setLogoUrl(uploadedUrl);
showToast('Logo carregado! URL: ' + uploadedUrl, 'success');
} else {
showToast('Erro: O servidor não retornou a URL da imagem.', 'error');
}
} catch (error) {
console.error(error);
showToast('Erro ao enviar o logo.', 'error');
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSaveBranding = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
await updateSettings({
appName,
appSubtitle,
appLogoUrl: logoUrl
});
showToast('Branding global atualizado com sucesso!', 'success');
} catch (error) {
showToast('Erro ao atualizar branding.', 'error');
console.error(error);
} finally {
setIsSaving(false);
}
};
if (isLoading) return <div className="p-8 text-center">Carregando configurações...</div>;
return (
<div className="p-8 animate-in fade-in duration-500">
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-indigo-500 font-bold mb-1">
<Terminal size={16} />
<span>Developer Access Only</span>
</div>
<h1 className="text-4xl font-black text-text-main tracking-tight">Dashboard do Desenvolvedor</h1>
<p className="text-text-muted mt-1">Gestão Global do Sistema TS-GPI</p>
</div>
</div>
{/* Tabs Navigation */}
<div className="flex items-center gap-2 p-1 bg-surface-soft rounded-2xl border border-border/20 w-fit">
<button
onClick={() => setActiveTab('overview')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'overview'
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<LayoutDashboard size={16} />
Visão Geral
</button>
<button
onClick={() => setActiveTab('organizations')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'organizations'
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Building2 size={16} />
Organizações
</button>
<button
onClick={() => setActiveTab('users')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'users'
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Users size={16} />
Usuários Globais
</button>
<button
onClick={() => setActiveTab('branding')}
className={clsx(
"flex items-center gap-2 px-4 py-2 text-sm font-bold rounded-xl transition-all",
activeTab === 'branding'
? "bg-surface text-indigo-500 shadow-sm border border-border/40"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<ImageIcon size={16} />
Branding Global
</button>
</div>
{/* Content Area */}
<div className="bg-surface rounded-3xl border border-border/40 min-h-[500px] overflow-hidden">
{activeTab === 'overview' && (
<div className="p-12 flex flex-col items-center justify-center text-center h-full">
<div className="w-16 h-16 rounded-2xl bg-indigo-500/10 flex items-center justify-center mb-6 text-indigo-500 ring-1 ring-indigo-500/20">
<Globe size={32} />
</div>
<h2 className="text-2xl font-bold text-text-main">Sistema Operacional</h2>
<p className="text-text-muted mt-2 max-w-sm">
Você está no nível mais alto de privilégios. Todas as alterações feitas aqui impactam todas as instâncias do app.
</p>
</div>
)}
{activeTab === 'branding' && (
<div className="p-8 max-w-2xl">
<h2 className="text-2xl font-bold mb-6 flex items-center gap-3">
<ImageIcon className="text-indigo-500" />
Identidade Visual do APP
</h2>
<form onSubmit={handleSaveBranding} className="space-y-6">
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Nome da Aplicação
</label>
<input
type="text"
value={appName}
onChange={(e) => setAppName(e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-surface-soft border border-border/50 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
placeholder="Ex: GPI"
/>
<p className="text-xs text-text-muted mt-1">Aparecerá no topo do menu lateral.</p>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Subtítulo
</label>
<input
type="text"
value={appSubtitle}
onChange={(e) => setAppSubtitle(e.target.value)}
className="w-full px-4 py-2 rounded-xl bg-surface-soft border border-border/50 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
placeholder="Ex: Gestão de Pintura Industrial"
/>
<p className="text-xs text-text-muted mt-1">Nome extenso abaixo da sigla.</p>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Logo do App (Público)
</label>
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<input
type="text"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
className="flex-1 px-4 py-2 rounded-xl bg-surface-soft border border-border/50 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all font-mono text-sm text-text-muted"
placeholder="https://... ou /uploads/..."
readOnly
/>
{logoUrl && (
<button
type="button"
onClick={() => setLogoUrl('')}
className="p-2 text-text-muted hover:text-error hover:bg-error/10 rounded-xl transition-colors"
title="Limpar logo"
>
<X size={20} />
</button>
)}
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center justify-center gap-2 w-full py-4 border-2 border-dashed border-border/60 rounded-xl hover:border-indigo-500 hover:bg-indigo-500/5 transition-all text-text-muted hover:text-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
) : (
<>
<UploadCloud size={20} />
<span className="font-bold text-sm">Carregar arquivo local (Desktop)</span>
</>
)}
</button>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
aria-label="Carregar logo do aplicativo"
/>
<p className="text-xs text-text-muted">A imagem será armazenada no servidor e acessível publicamente.</p>
</div>
</div>
{/* Preview */}
<div className="p-4 bg-surface-soft rounded-xl border border-border/30">
<span className="text-xs font-bold text-text-muted uppercase tracking-wider mb-2 block">Preview</span>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0 overflow-hidden shadow-lg">
{logoUrl ? (
<img src={logoUrl} alt="Preview" className="w-full h-full object-cover" />
) : (
<span className="text-white font-black text-xl">{appName?.[0] || 'G'}</span>
)}
</div>
<div>
<h1 className="font-black text-xl tracking-tight text-text-main leading-none">
{appName || 'Nome App'}
</h1>
<p className="text-[10px] font-bold text-indigo-500 uppercase tracking-wider mt-1">
{appSubtitle || 'Subtítulo do App'}
</p>
</div>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isSaving}
className={clsx(
"px-6 py-3 rounded-xl font-bold text-white shadow-lg shadow-indigo-500/20 transition-all flex items-center gap-2",
isSaving
? "bg-indigo-400 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-500 hover:scale-[1.02]"
)}
>
{isSaving ? 'Salvando...' : 'Salvar Alterações Globais'}
</button>
</div>
</form>
</div>
)}
{(activeTab === 'organizations') && (
<div className="p-8">
<h2 className="text-xl font-bold mb-6 flex items-center gap-2">
<Building2 className="text-indigo-500" />
Estrutura Organizacional
</h2>
<p className="text-text-muted mb-6">Visão hierárquica das organizações e seus membros.</p>
{isLoadingData ? (
<div className="text-center py-8 text-text-muted">Carregando organizações...</div>
) : (
<div className="space-y-6">
{globalOrgs.map((org) => {
const admins = org.members.filter(m => m.role === 'admin');
const commonUsers = org.members.filter(m => m.role !== 'admin');
return (
<div key={org._id} className={clsx(
"bg-surface-soft border rounded-xl overflow-hidden transition-all",
org.isBanned
? "border-red-500/50 shadow-[0_0_15px_rgba(239,68,68,0.1)]"
: "border-border/40 hover:border-indigo-500/30"
)}>
<div className="p-4 bg-surface border-b border-border/40 flex justify-between items-center">
<div>
<div className="flex items-center gap-2 mb-1">
<div className="text-xs font-bold text-text-muted uppercase tracking-wider">Organização</div>
{org.isBanned && (
<span className="px-2 py-0.5 bg-red-500/10 text-red-500 rounded text-[10px] font-bold border border-red-500/20 flex items-center gap-1">
<X size={10} /> BLOQUEADA
</span>
)}
{!org.isBanned && (
<span className="px-2 py-0.5 bg-green-500/10 text-green-500 rounded text-[10px] font-bold border border-green-500/20">
ATIVA
</span>
)}
</div>
<div className="font-bold text-text-main text-base mb-0.5">{org.name || 'Organização Sem Nome'}</div>
<div className="font-mono text-indigo-400/80 text-xs select-all" title="ID da Organização">{org._id}</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-xs font-bold text-text-muted uppercase tracking-wider mb-1">Total de Membros</div>
<span className="bg-surface-hover px-2 py-1 rounded text-sm font-bold">{org.memberCount}</span>
</div>
<button
onClick={async () => {
if (confirm(org.isBanned ? 'Deseja desbloquear esta organização?' : 'Deseja BLOQUEAR esta organização? Os usuários perderão o acesso imediatamente.')) {
try {
await systemSettingsService.toggleOrganizationBan(org._id, !org.isBanned);
showToast(`Organização ${org.isBanned ? 'desbloqueada' : 'bloqueada'} com sucesso!`, 'success');
// Refresh list
const data = await systemSettingsService.getGlobalOrganizations();
setGlobalOrgs(data);
} catch (e) {
console.error(e);
showToast('Erro ao alterar status.', 'error');
}
}
}}
className={clsx(
"p-2 rounded-lg transition-colors border",
org.isBanned
? "border-green-500/30 bg-green-500/10 text-green-500 hover:bg-green-500/20"
: "border-red-500/30 bg-red-500/10 text-red-500 hover:bg-red-500/20"
)}
title={org.isBanned ? "Desbloquear Organização" : "Bloquear Organização"}
>
{org.isBanned ? (
<Users size={18} /> // Unblock icon
) : (
<X size={18} />
)}
</button>
</div>
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-6 opacity-90">
{/* Admins Column */}
<div>
<h3 className="text-sm font-bold text-indigo-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<Users size={14} /> Administradores
</h3>
<div className="space-y-2">
{admins.map(admin => (
<div key={admin.clerkUserId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-indigo-500/20">
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-500 font-bold text-xs">
{admin.name.charAt(0).toUpperCase()}
</div>
<div className="overflow-hidden">
<div className="font-bold text-sm truncate text-text-main">{admin.name}</div>
<div className="text-[10px] text-text-muted truncate">{admin.email}</div>
</div>
{admin.isBanned && <span className="ml-auto text-error text-[10px] uppercase font-bold">Banido</span>}
</div>
))}
{admins.length === 0 && <p className="text-sm text-text-muted italic">Sem administradores.</p>}
</div>
</div>
{/* Users Column */}
<div>
<h3 className="text-sm font-bold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
<Users size={14} /> Usuários & Convidados
</h3>
<div className="space-y-2">
{commonUsers.map(user => (
<div key={user.clerkUserId} className="flex items-center gap-3 p-2 bg-surface rounded-lg border border-border/40">
<div className={clsx(
"w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs",
user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"
)}>
{user.name.charAt(0).toUpperCase()}
</div>
<div className="overflow-hidden">
<div className="font-bold text-sm truncate text-text-main">{user.name}</div>
<div className="text-[10px] text-text-muted truncate">{user.email}</div>
</div>
<span className={clsx(
"ml-auto text-[10px] uppercase font-bold px-1.5 py-0.5 rounded",
user.role === 'user' ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"
)}>
{user.role}
</span>
{user.isBanned && <span className="text-error text-[10px] uppercase font-bold ml-1">Banido</span>}
</div>
))}
{commonUsers.length === 0 && <p className="text-sm text-text-muted italic">Sem outros membros.</p>}
</div>
</div>
</div>
<div className="px-4 py-2 bg-surface border-t border-border/40 text-[10px] text-text-muted text-right">
Última atividade: {org.lastActive ? new Date(org.lastActive).toLocaleDateString() + ' ' + new Date(org.lastActive).toLocaleTimeString() : 'N/A'}
</div>
</div>
);
})}
{globalOrgs.length === 0 && (
<div className="p-8 text-center text-text-muted bg-surface-soft rounded-xl border border-border/40">
Nenhuma organização encontrada.
</div>
)}
</div>
)}
</div>
)}
{(activeTab === 'users') && (
<div className="p-8">
<h2 className="text-xl font-bold mb-6 flex items-center gap-2">
<Users className="text-indigo-500" />
Base Global de Usuários
</h2>
{isLoadingData ? (
<div className="text-center py-8 text-text-muted">Carregando usuários...</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-border/40 text-xs text-text-muted uppercase tracking-wider">
<th className="p-4">Nome / Email</th>
<th className="p-4">Role Global</th>
<th className="p-4">Status</th>
<th className="p-4 text-right">Cadastrado em</th>
</tr>
</thead>
<tbody>
{globalUsers.map((user) => (
<tr key={user._id} className="border-b border-border/20 hover:bg-surface-soft/50 transition-colors">
<td className="p-4">
<div className="font-bold text-text-main">{user.name}</div>
<div className="text-xs text-text-muted">{user.email}</div>
</td>
<td className="p-4">
<span className={clsx(
"px-2 py-1 rounded text-xs font-bold uppercase",
user.role === 'admin' ? "bg-indigo-500/10 text-indigo-500" :
user.role === 'user' ? "bg-green-500/10 text-green-500" :
"bg-gray-500/10 text-gray-500"
)}>
{user.role}
</span>
</td>
<td className="p-4">
{user.isBanned ? (
<span className="text-error font-bold text-xs flex items-center gap-1">
<X size={12} /> Banido
</span>
) : (
<span className="text-green-500 font-bold text-xs">Ativo</span>
)}
</td>
<td className="p-4 text-right text-text-muted text-sm">
{new Date(user.createdAt).toLocaleDateString()}
</td>
</tr>
))}
{globalUsers.length === 0 && (
<tr>
<td colSpan={4} className="p-8 text-center text-text-muted">Nenhum usuário encontrado.</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,198 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import { useAuth } from '../context/useAuth';
import { LayoutDashboard, User } from 'lucide-react';
import type { Project, PaintingScheme } from '../types';
import { ColorBubble } from '../components/ColorBubble';
interface DashboardProject extends Project {
schemes: (Partial<PaintingScheme> & { colorHex?: string })[];
paintedWeight?: number;
}
export const GuestDashboard: React.FC = () => {
const [projects, setProjects] = useState<DashboardProject[]>([]);
const [loading, setLoading] = useState(true);
useAuth();
const navigate = useNavigate();
useEffect(() => {
const fetchProjects = async () => {
try {
const response = await api.get('/projects/dashboard');
setProjects(response.data);
} catch (error) {
console.error('Error fetching dashboard projects:', error);
} finally {
setLoading(false);
}
};
fetchProjects();
}, []);
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-8 animate-in fade-in duration-700">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 flex items-center justify-center text-indigo-500 shadow-sm">
<LayoutDashboard className="w-6 h-6" />
</div>
<div>
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">Painel Principal</h1>
<p className="text-sm text-text-muted font-medium tracking-wide">Visão geral das obras e esquemas ativos</p>
</div>
</div>
{/* Projects Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{projects.map((project) => (
<div key={project.id} className="group relative">
{/* Card Background Bloom Effect */}
<div className="absolute -inset-1 bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 rounded-[32px] blur-xl opacity-0 group-hover:opacity-100 transition-all duration-700"></div>
<div
onClick={() => navigate(`/project/${project.id}`)}
className="p-0 flex flex-col h-full relative overflow-hidden bg-surface border border-border/40 rounded-[32px] group-hover:border-primary/40 transition-all duration-500 shadow-soft group-hover:shadow-2xl cursor-pointer"
>
{/* Header Section */}
<div className="p-8 pb-4 relative">
<div className="flex justify-between items-start mb-6">
<div className="flex flex-col gap-2">
<span className="text-[10px] font-black text-primary uppercase tracking-[0.25em]">{project.client || 'CLIENTE N/D'}</span>
<h3 className="text-2xl font-black text-text-main tracking-tighter leading-none group-hover:text-primary transition-colors" title={project.name}>
{project.name}
</h3>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1 bg-primary/10 text-primary text-[9px] font-black uppercase tracking-widest rounded-full border border-primary/20">
OBRA ATIVA
</span>
{project.technician && (
<span className="px-3 py-1 bg-surface-highlight text-text-secondary text-[9px] font-black uppercase tracking-widest rounded-full border border-border/50">
RESP: {project.technician}
</span>
)}
</div>
</div>
{/* Body Section */}
<div className="px-8 space-y-5 flex-1">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Peso Total</span>
<span className="text-xl font-black text-text-main">
{project.weightKg ? Number(project.weightKg).toLocaleString('pt-BR') : '0'} <span className="text-[9px] text-text-muted">kg</span>
</span>
</div>
<div className="p-4 bg-surface-soft/50 rounded-2xl border border-border/20 flex flex-col">
<span className="text-[9px] text-text-muted font-bold uppercase tracking-wider mb-1">Evolução</span>
<span className="text-xl font-black text-text-main">
{project.weightKg && project.paintedWeight !== undefined
? Math.min(Math.round((project.paintedWeight / project.weightKg) * 100), 100)
: 0}
<span className="text-[9px] text-text-muted">%</span>
</span>
</div>
</div>
{/* Schemes List */}
{project.schemes && project.schemes.length > 0 && (
<div className="py-1">
<div className="text-[10px] font-bold text-text-muted uppercase tracking-widest border-b border-border/40 pb-2 mb-3">Esquemas de Pintura</div>
<div className="space-y-2">
{project.schemes.slice(0, 2).map((scheme, index) => (
<div key={index} className="p-3 bg-surface-soft/50 rounded-xl border border-border/20">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-[10px] font-black text-primary uppercase tracking-wide mb-1">
{scheme.coat || scheme.type || 'Camada'}
</div>
<div className="text-sm font-black text-text-main line-clamp-1 mb-1.5">
{scheme.name}
</div>
{/* Info Row: Cor */}
{scheme.color && (
<div className="flex items-center gap-1 mb-1.5">
<span className="w-1 h-1 rounded-full bg-text-muted opacity-50"></span>
<span className="text-[13px] font-bold text-text-muted">
Cor: {scheme.color}
</span>
</div>
)}
{/* Info Row: EPS e Diluente */}
<div className="flex flex-wrap items-center gap-2">
{(scheme.epsMin || scheme.epsMax) && (
<span className="font-mono bg-primary/5 text-primary px-2 py-1 rounded border border-primary/10 font-bold text-[13px]">
EPS: {scheme.epsMin}-{scheme.epsMax}µm
</span>
)}
{scheme.thinnerSymbol && (
<span className="px-2 py-1 bg-surface-highlight text-text-secondary rounded border border-border/50 font-bold text-[13px]">
{scheme.thinnerSymbol}
</span>
)}
</div>
</div>
{scheme.colorHex && (
<ColorBubble colorHex={scheme.colorHex} className="w-6 h-6 shadow-md flex-shrink-0" />
)}
</div>
</div>
))}
{project.schemes.length > 2 && (
<div className="text-[9px] text-center text-primary font-black bg-primary/5 py-1.5 rounded border border-primary/10">
+ {project.schemes.length - 2} ESQUEMAS ADICIONAIS
</div>
)}
</div>
</div>
)}
</div>
{/* Footer Section */}
<div className="p-8 pt-6 border-t border-border/40 mt-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-surface-highlight flex items-center justify-center border border-border/40">
<User size={14} className="text-text-muted" />
</div>
<div className="flex flex-col">
<span className="text-[9px] font-black text-text-muted uppercase tracking-tighter">Responsável</span>
<span className="text-[11px] font-bold text-text-secondary">{project.technician || 'Não Definido'}</span>
</div>
</div>
<div className="flex items-center gap-2.5 h-10 px-6 bg-surface-highlight hover:bg-primary hover:text-white border border-border/40 rounded-xl text-xs font-black transition-all group/btn">
<LayoutDashboard size={14} className="group-hover/btn:scale-110 transition-transform" />
VER PROJETO
</div>
</div>
</div>
</div>
))}
{projects.length === 0 && (
<div className="col-span-full py-20 text-center">
<div className="mx-auto w-16 h-16 bg-surface-soft rounded-2xl flex items-center justify-center text-text-muted mb-4">
<LayoutDashboard size={32} />
</div>
<h3 className="text-xl font-bold text-text-main">Nenhum projeto encontrado</h3>
<p className="text-text-muted mt-2">Você não possui acesso a nenhum projeto ativo no momento.</p>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,344 @@
import React, { useEffect, useState } from 'react';
import api from '../services/api';
import { Button } from '../components/Button';
import { Plus, Pencil, Trash2, ClipboardCheck, Search, Paintbrush, Hammer, Camera } from 'lucide-react';
import { MobileList } from '../components/MobileList';
import { CreateInspectionModal } from '../components/modals/CreateInspectionModal';
import { format } from 'date-fns';
import type { Inspection, Project } from '../types';
import { clsx } from 'clsx';
export const InspectionsList: React.FC = () => {
const [inspections, setInspections] = useState<Inspection[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [editItem, setEditItem] = useState<Inspection | undefined>(undefined);
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
try {
// Fetch inspections
try {
const response = await api.get('/inspections');
console.log('Inspeções carregadas do banco:', response.data);
setInspections(response.data);
} catch (error) {
console.error('Error fetching inspections:', error);
}
// Fetch projects for lookup
try {
const projRes = await api.get('/projects');
setProjects(projRes.data);
} catch (error) {
console.error('Error fetching projects:', error);
}
} finally {
setLoading(false);
}
};
const fetchInspections = fetchData; // Alias for compatibility with existing calls
const handleDelete = async (id: string) => {
if (!window.confirm('Tem certeza que deseja excluir esta inspeção?')) return;
try {
await api.delete(`/inspections/${id}`);
fetchData();
} catch (error) {
console.error('Error deleting inspection', error);
}
};
const filteredInspections = inspections.filter(i => {
const project = projects.find(p => p.id === i.projectId);
const projectName = project?.name?.toLowerCase() || '';
const clientName = project?.client?.toLowerCase() || '';
const search = searchTerm.toLowerCase();
return (
(i.inspector && i.inspector.toLowerCase().includes(search)) ||
(i.pieceDescription && i.pieceDescription.toLowerCase().includes(search)) ||
(i.defects && i.defects.toLowerCase().includes(search)) ||
(i.type === 'surface_treatment' && i.batch?.toLowerCase().includes(search)) ||
projectName.includes(search) ||
clientName.includes(search)
);
});
const getStatusIndicator = (targetType: 'painting' | 'surface_treatment', currentItem: Inspection) => {
const normalize = (s: string | undefined | null) =>
(s || '').normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
const getPid = (p: string | { _id?: string; id?: string } | null | undefined): string => {
if (!p) return '';
if (typeof p === 'string') return p;
const obj = p as { _id?: string; id?: string };
return obj._id || obj.id || String(p);
};
const tProj = getPid(currentItem.projectId);
const tDesc = normalize(currentItem.pieceDescription);
const tBatch = normalize(currentItem.batch);
const matches = inspections.filter(ins => {
const iProj = getPid(ins.projectId);
const iType = ins.type || 'painting';
if (iProj !== tProj || iType !== targetType) return false;
const iDesc = normalize(ins.pieceDescription);
const iBatch = normalize(ins.batch);
return (tDesc !== '' && (tDesc === iDesc || tDesc === iBatch)) ||
(tBatch !== '' && (tBatch === iDesc || tBatch === iBatch));
});
// DEBUG AVANÇADO: Só logamos quando falha em achar uma peça que deveria existir
if (matches.length === 0 && (tDesc.includes('perfis') || tDesc.includes('chapas'))) {
const inSameProject = inspections.filter(ins => getPid(ins.projectId) === tProj);
const otherStages = inSameProject.filter(ins => (ins.type || 'painting') === targetType);
if (otherStages.length > 0) {
console.group(`Diagnóstico de Falha: ${targetType} para "${tDesc}"`);
console.log("Peça atual:", { desc: tDesc, lote: tBatch, proj: tProj });
console.log(`Encontrei ${otherStages.length} registros de ${targetType} no projeto, mas nenhum casou com a descrição/lote.`);
console.log("Dados dos registros ignorados para conferência:", otherStages.map(v => ({ desc: v.pieceDescription, lote: v.batch })));
console.groupEnd();
}
}
if (matches.length === 0) return { status: 'none', color: 'border-text-muted/30 text-text-muted/30' };
const latest = [...matches].sort((a, b) => {
const dA = a.date ? new Date(a.date).getTime() : 0;
const dB = b.date ? new Date(b.date).getTime() : 0;
return dB - dA;
})[0];
const app = latest.appearance;
if (app === 'rejected') return { status: 'rejected', color: 'bg-error border-error text-error' };
if (app === 'notes' || app === 'warning') return { status: 'warning', color: 'bg-amber-500 border-amber-500 text-amber-500' };
if (app === 'approved') return { status: 'approved', color: 'bg-success border-success text-success' };
return { status: 'none', color: 'border-text-muted/30 text-text-muted/30' };
};
const columns = [
{
header: 'Tipo',
accessor: (i: Inspection) => (
<div className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center",
i.type === 'surface_treatment' ? "bg-amber-100 text-amber-600" : "bg-primary/10 text-primary"
)}>
{i.type === 'surface_treatment' ? <Hammer size={16} /> : <Paintbrush size={16} />}
</div>
),
className: "w-12"
},
{
header: 'Obra / Projeto',
accessor: (i: Inspection) => {
const project = projects.find(p => p.id === i.projectId);
return (
<div className="flex flex-col">
<span className="font-bold text-text-main text-xs uppercase tracking-tight truncate max-w-[150px]" title={project?.name}>
{project?.name || '---'}
</span>
<span className="text-[10px] text-text-muted font-bold uppercase truncate max-w-[150px]">{project?.client || '---'}</span>
</div>
);
}
},
{
header: 'Data / Inspetor',
accessor: (i: Inspection) => (
<div className="flex flex-col">
<span className="font-bold text-text-main text-xs uppercase tracking-tight">
{i.date ? format(new Date(i.date), 'dd/MM/yyyy') : 'Sem Data'}
</span>
<span className="text-[10px] text-text-muted font-bold uppercase">{i.inspector || '---'}</span>
</div>
)
},
{
header: 'Detalhes',
accessor: (i: Inspection) => (
<div className="flex flex-col">
<span className="text-text-main font-bold text-xs uppercase">{i.pieceDescription || '--'}</span>
{i.type === 'surface_treatment' ? (
<div className="flex gap-2 text-[10px] text-text-muted mt-0.5">
<span className="bg-surface-hover px-1.5 rounded">{i.treatmentType === 'dry_abrasive_blasting' ? 'Jateamento' : i.treatmentType}</span>
<span className="bg-surface-hover px-1.5 rounded font-bold">{i.cleaningDegree}</span>
</div>
) : (
<span className="text-[10px] text-text-muted mt-0.5">Pintura Convencional</span>
)}
</div>
)
},
{
header: 'Peso (Kg)',
accessor: (i: Inspection) => (
<div className="flex flex-col items-center">
<span className="text-text-main font-bold text-xs">{i.weightKg ? i.weightKg.toLocaleString('pt-BR') : '0'}</span>
<span className="text-[10px] text-text-muted font-bold tracking-tighter uppercase">KG</span>
</div>
)
},
{
header: 'Evidências',
accessor: (i: Inspection) => i.photos && i.photos.length > 0 ? (
<div className="flex items-center gap-1 text-text-muted text-xs font-medium">
<Camera size={14} />
<span>{i.photos.length}</span>
</div>
) : null,
className: "hidden md:table-cell"
},
{
header: 'Situação',
accessor: (i: Inspection) => {
const treat = getStatusIndicator('surface_treatment', i);
const paint = getStatusIndicator('painting', i);
const isAnyRejected = treat.status === 'rejected' || paint.status === 'rejected';
const isBothApproved = treat.status === 'approved' && paint.status === 'approved';
const isAnyNotes = treat.status === 'warning' || paint.status === 'warning';
return (
<span className={clsx(
"px-2 py-0.5 rounded text-[10px] font-black uppercase whitespace-nowrap",
isAnyRejected ? "bg-error/10 text-error" :
isBothApproved ? "bg-success/10 text-success" :
isAnyNotes ? "bg-amber-500/10 text-amber-500" :
"bg-surface-soft text-text-muted"
)}>
{isAnyRejected ? 'Reprovado' :
isBothApproved ? 'Aprovado' :
isAnyNotes ? 'Ressalvas' :
'Pendente'}
</span>
);
}
},
{
header: 'Realizado',
accessor: (i: Inspection) => {
const treat = getStatusIndicator('surface_treatment', i);
const paint = getStatusIndicator('painting', i);
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className={clsx("w-2.5 h-2.5 rounded-full border-2", treat.color)}></div>
<span className={clsx("text-[10px] font-bold uppercase", treat.status === 'none' ? "text-text-muted/50" : "text-text-secondary")}>Tratamento</span>
</div>
<div className="flex items-center gap-2">
<div className={clsx("w-2.5 h-2.5 rounded-full border-2", paint.color)}></div>
<span className={clsx("text-[10px] font-bold uppercase", paint.status === 'none' ? "text-text-muted/50" : "text-text-secondary")}>Pintura</span>
</div>
</div>
);
}
}
];
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
<ClipboardCheck className="w-8 h-8" />
</div>
<div>
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Inspeções</h1>
<p className="text-sm text-text-muted font-medium tracking-widest uppercase text-xs">Controle de qualidade e conformidade</p>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="relative hidden lg:block group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar inspeções..."
className="h-14 w-64 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
<Plus className="w-5 h-5 mr-2" />
Nova Inspeção
</Button>
</div>
</div>
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
<MobileList
data={filteredInspections}
columns={columns}
keyExtractor={(item) => item.id}
titleAccessor={(item) => item.date ? format(new Date(item.date), 'dd/MM/yyyy') : 'Sem data'}
subtitleAccessor={(item) => `${item.inspector || ''} - ${item.pieceDescription || ''}`}
actionRender={(item) => (
<div className="flex gap-1 justify-end">
<button
onClick={() => { setEditItem(item); setIsModalOpen(true); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
aria-label="Editar inspeção"
title="Editar inspeção"
>
<Pencil size={18} />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
aria-label="Excluir inspeção"
title="Excluir inspeção"
>
<Trash2 size={18} />
</button>
</div>
)}
/>
{filteredInspections.length === 0 && (
<div className="text-center py-24">
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6 border border-border/20">
<ClipboardCheck className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-text-main">Nenhuma inspeção encontrada</h3>
<p className="mt-2 text-text-muted font-medium">Os registros de qualidade aparecerão aqui após serem realizados.</p>
</div>
)}
</div>
<CreateInspectionModal
isOpen={isModalOpen}
onClose={() => { setIsModalOpen(false); setEditItem(undefined); }}
onSuccess={fetchInspections}
initialData={editItem}
existingInspections={inspections}
/>
</div>
);
};

View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect, useCallback } from 'react';
import api from '../services/api';
import type { IInstrument, CreateInstrumentDTO } from '../types/Instrument';
import { Plus, Search, Edit2, Trash2, Calendar, Gauge, XCircle } from 'lucide-react';
import { useAuth } from '../context/useAuth';
import { useToast } from '../context/ToastContext';
const InstrumentList: React.FC = () => {
const [instruments, setInstruments] = useState<IInstrument[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingInstrument, setEditingInstrument] = useState<IInstrument | null>(null);
const { showToast } = useToast();
const { isAdmin } = useAuth();
// Form State
const [formData, setFormData] = useState<CreateInstrumentDTO>({
name: '',
type: '',
manufacturer: '',
modelName: '',
serialNumber: '',
calibrationDate: '',
calibrationExpirationDate: '',
status: 'active',
notes: ''
});
const fetchInstruments = useCallback(async () => {
try {
setLoading(true);
const response = await api.get('/instruments');
setInstruments(response.data);
} catch (error) {
console.error('Failed to fetch instruments', error);
showToast('Erro ao carregar instrumentos', 'error');
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
fetchInstruments();
}, [fetchInstruments]);
const handleOpenModal = (instrument?: IInstrument) => {
if (instrument) {
setEditingInstrument(instrument);
setFormData({
name: instrument.name,
type: instrument.type,
manufacturer: instrument.manufacturer || '',
modelName: instrument.modelName || '',
serialNumber: instrument.serialNumber,
calibrationDate: instrument.calibrationDate ? instrument.calibrationDate.split('T')[0] : '',
calibrationExpirationDate: instrument.calibrationExpirationDate ? instrument.calibrationExpirationDate.split('T')[0] : '',
status: instrument.status,
notes: instrument.notes || ''
});
} else {
setEditingInstrument(null);
setFormData({
name: '',
type: '',
manufacturer: '',
modelName: '',
serialNumber: '',
calibrationDate: '',
calibrationExpirationDate: '',
status: 'active',
notes: ''
});
}
setIsModalOpen(true);
};
const handleDelete = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir este instrumento?')) return;
try {
await api.delete(`/instruments/${id}`);
setInstruments(instruments.filter(i => i._id !== id));
showToast('Instrumento excluído com sucesso', 'success');
} catch (error) {
console.error('Failed to delete instrument', error);
showToast('Erro ao excluir instrumento', 'error');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingInstrument) {
const response = await api.put(`/instruments/${editingInstrument._id}`, formData);
setInstruments(instruments.map(i => i._id === editingInstrument._id ? response.data : i));
showToast('Instrumento atualizado com sucesso', 'success');
} else {
const response = await api.post('/instruments', formData);
setInstruments([...instruments, response.data]);
showToast('Instrumento criado com sucesso', 'success');
}
setIsModalOpen(false);
} catch (error) {
console.error('Failed to save instrument', error);
showToast('Erro ao salvar instrumento', 'error');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'bg-green-500/10 text-green-500';
case 'inactive': return 'bg-gray-500/10 text-gray-500';
case 'maintenance': return 'bg-yellow-500/10 text-yellow-500';
case 'expired': return 'bg-red-500/10 text-red-500';
default: return 'bg-gray-500/10 text-gray-500';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'active': return 'Ativo';
case 'inactive': return 'Inativo';
case 'maintenance': return 'Manutenção';
case 'expired': return 'Vencido';
default: return status;
}
};
const filteredInstruments = instruments.filter(i =>
i.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.serialNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.type.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-text-main">Instrumentos de Medição</h1>
<p className="text-text-muted text-sm">Gerencie seus instrumentos e calibrações</p>
</div>
{isAdmin() && (
<button
onClick={() => handleOpenModal()}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors shadow-lg shadow-primary/20"
>
<Plus size={20} />
Novo Instrumento
</button>
)}
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" size={20} />
<input
type="text"
placeholder="Buscar por nome, serial ou tipo..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-surface rounded-xl border border-border/40 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all"
/>
</div>
{/* List */}
{loading ? (
<div className="text-center py-10 text-text-muted">Carregando...</div>
) : filteredInstruments.length === 0 ? (
<div className="text-center py-20 bg-surface rounded-2xl border border-border/40 border-dashed">
<Gauge size={48} className="mx-auto text-text-muted/50 mb-4" />
<h3 className="text-lg font-bold text-text-main">Nenhum instrumento encontrado</h3>
<p className="text-text-muted text-sm mt-1">Cadastre seus instrumentos para controlar a calibração.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredInstruments.map((instrument) => (
<div key={instrument._id} className="bg-surface p-5 rounded-2xl border border-border/40 hover:border-primary/50 transition-all group">
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-primary/10 rounded-xl text-primary">
<Gauge size={24} />
</div>
<span className={`px-2 py-1 rounded-lg text-xs font-bold uppercase ${getStatusColor(instrument.status)}`}>
{getStatusLabel(instrument.status)}
</span>
</div>
<h3 className="text-lg font-bold text-text-main mb-1">{instrument.name}</h3>
<p className="text-sm text-text-muted mb-4">{instrument.type} {instrument.manufacturer}</p>
<div className="space-y-2 mb-6">
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span className="font-mono bg-surface-hover px-1.5 rounded text-xs">S/N: {instrument.serialNumber}</span>
</div>
{instrument.calibrationExpirationDate && (
<div className="flex items-center gap-2 text-sm">
<Calendar size={14} className={new Date(instrument.calibrationExpirationDate) < new Date() ? "text-red-500" : "text-text-muted"} />
<span className={new Date(instrument.calibrationExpirationDate) < new Date() ? "text-red-500 font-bold" : "text-text-secondary"}>
Vence: {new Date(instrument.calibrationExpirationDate).toLocaleDateString()}
</span>
</div>
)}
</div>
{isAdmin() && (
<div className="flex gap-2 pt-4 border-t border-border/40">
<button
onClick={() => handleOpenModal(instrument)}
className="flex-1 py-2 rounded-lg bg-surface-hover hover:bg-surface-hover/80 text-text-main text-sm font-semibold transition-colors flex items-center justify-center gap-2"
>
<Edit2 size={16} /> Editar
</button>
<button
onClick={() => handleDelete(instrument._id)}
className="p-2 rounded-lg hover:bg-red-500/10 text-text-muted hover:text-red-500 transition-colors"
title="Excluir Instrumento"
aria-label="Excluir Instrumento"
>
<Trash2 size={18} />
</button>
</div>
)}
</div>
))}
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-surface w-full max-w-lg rounded-2xl shadow-2xl border border-border/40 p-6 animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-text-main">
{editingInstrument ? 'Editar Instrumento' : 'Novo Instrumento'}
</h2>
<button onClick={() => setIsModalOpen(false)} className="text-text-muted hover:text-text-main" title="Fechar Modal">
<XCircle size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Nome</label>
<input
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
placeholder="Ex: Higrômetro"
/>
</div>
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Tipo</label>
<input
type="text"
required
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
placeholder="Ex: Ambiental"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Fabricante</label>
<input
type="text"
value={formData.manufacturer}
onChange={e => setFormData({ ...formData, manufacturer: e.target.value })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
placeholder="Ex: Minipa"
title="Fabricante do instrumento"
/>
</div>
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Modelo</label>
<input
type="text"
value={formData.modelName}
onChange={e => setFormData({ ...formData, modelName: e.target.value })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
placeholder="Ex: ET-2000"
title="Modelo do instrumento"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Número de Série</label>
<input
type="text"
required
value={formData.serialNumber}
onChange={e => setFormData({ ...formData, serialNumber: e.target.value })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main font-mono"
placeholder="Ex: 123456"
title="Número de série"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Data Calibração</label>
<input
type="date"
value={formData.calibrationDate}
onChange={e => setFormData({ ...formData, calibrationDate: e.target.value })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
title="Data da última calibração"
/>
</div>
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Vencimento</label>
<input
type="date"
value={formData.calibrationExpirationDate}
onChange={e => setFormData({ ...formData, calibrationExpirationDate: e.target.value })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
title="Data de vencimento da calibração"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Status</label>
<select
value={formData.status}
onChange={e => setFormData({ ...formData, status: e.target.value as IInstrument['status'] })}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main"
title="Status do instrumento"
>
<option value="active">Ativo</option>
<option value="inactive">Inativo</option>
<option value="maintenance">Em Manutenção</option>
<option value="expired">Vencido</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-text-muted uppercase mb-1">Observações</label>
<textarea
value={formData.notes}
onChange={e => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-surface-soft rounded-lg border border-border/40 focus:border-primary outline-none text-text-main resize-none"
placeholder="Observações adicionais..."
title="Observações"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="flex-1 py-3 rounded-xl bg-surface-hover text-text-main font-bold hover:bg-surface-hover/80 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 py-3 rounded-xl bg-primary text-white font-bold hover:bg-primary-dark transition-colors shadow-lg shadow-primary/20"
>
Salvar
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default InstrumentList;

View File

@@ -0,0 +1,37 @@
import { SignIn } from "@clerk/clerk-react";
import { Hammer } from "lucide-react";
export const Login = () => {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft relative overflow-hidden">
{/* Background decorative elements */}
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] animate-pulse" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-primary/5 rounded-full blur-[120px]" />
<div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center">
{/* Logo Area */}
<div className="mb-8 flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center text-white font-bold text-3xl shadow-2xl shadow-primary/40 mb-4 animate-in zoom-in duration-700">
G
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight mb-1">GPI</h1>
<p className="text-text-muted text-sm font-medium uppercase tracking-widest">Gestão de Pintura Industrial</p>
</div>
{/* Clerk SignIn Component - Customizado via Tema Global no main.tsx */}
<div className="w-full bg-surface rounded-[2rem] border border-border/40 shadow-2xl shadow-primary/5 p-4 animate-in slide-in-from-bottom-8 duration-1000">
<SignIn
afterSignInUrl="/"
afterSignUpUrl="/"
forceRedirectUrl="/"
/>
</div>
<div className="mt-8 flex items-center gap-2 text-text-muted/60 text-xs font-medium">
<Hammer size={14} />
<span>© 2026 GPI - Eficiência Industrial</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useState } from 'react';
import { useUser, useOrganizationList, useOrganization } from '@clerk/clerk-react';
import { Building2, Users, RefreshCw, Mail } from 'lucide-react';
export const OrganizationSelector: React.FC = () => {
const { user } = useUser();
const { setActive, userMemberships, userInvitations } = useOrganizationList({
userMemberships: {
infinite: true,
},
userInvitations: {
infinite: true,
}
});
const { organization } = useOrganization();
const [isAcceptingInvites, setIsAcceptingInvites] = useState(false);
console.log('OrganizationSelector rendered');
console.log('Current organization:', organization);
console.log('User memberships:', userMemberships);
console.log('User memberships data:', userMemberships.data);
console.log('User invitations:', userInvitations);
console.log('User invitations data:', userInvitations.data);
// Auto-accept pending invitations
useEffect(() => {
const acceptPendingInvitations = async () => {
if (userInvitations.data && userInvitations.data.length > 0 && !isAcceptingInvites) {
console.log('Found pending invitations, auto-accepting...');
setIsAcceptingInvites(true);
for (const invitation of userInvitations.data) {
try {
console.log('Accepting invitation:', invitation);
await invitation.accept();
console.log('Invitation accepted successfully');
} catch (error) {
console.error('Error accepting invitation:', error);
}
}
// Reload memberships after accepting invitations
setTimeout(() => {
window.location.reload();
}, 1000);
}
};
acceptPendingInvitations();
}, [userInvitations.data, isAcceptingInvites]);
// Auto-select if user has only one organization
useEffect(() => {
console.log('Auto-select effect running...');
if (!organization && userMemberships.data && userMemberships.data.length === 1) {
console.log('Auto-selecting single organization...');
const membership = userMemberships.data[0];
if (setActive) {
setActive({ organization: membership.organization });
}
}
}, [organization, userMemberships.data, setActive]);
const handleSelectOrganization = async (orgId: string) => {
console.log('Selecting organization:', orgId);
if (setActive) {
await setActive({ organization: orgId });
}
// The auth context will automatically sync after organization changes
};
// Loading state - check if data exists or accepting invites
if (!userMemberships.data || isAcceptingInvites) {
console.log('Loading state - no data yet or accepting invites');
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
{isAcceptingInvites ? (
<>
<Mail className="w-12 h-12 text-primary animate-bounce mx-auto mb-4" />
<p className="text-text-main font-bold mb-2">Aceitando convites pendentes...</p>
<p className="text-text-muted text-sm">Por favor aguarde</p>
</>
) : (
<>
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-text-muted">Carregando organizações...</p>
</>
)}
</div>
</div>
);
}
if (userMemberships.data?.length === 0) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl border border-border/40 p-8 text-center">
<div className="w-16 h-16 rounded-2xl bg-amber-500/20 flex items-center justify-center mx-auto mb-4">
<Building2 className="w-8 h-8 text-amber-500" />
</div>
<h1 className="text-2xl font-bold text-text-main mb-2">
Nenhuma Organização
</h1>
<p className="text-text-muted mb-6">
Você ainda não faz parte de nenhuma organização. Entre em contato com o administrador para receber um convite.
</p>
<div className="text-sm text-text-muted bg-surface-soft rounded-lg p-4">
<p className="font-semibold mb-1">Conectado como:</p>
<p>{user?.primaryEmailAddress?.emailAddress}</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-primary/20 flex items-center justify-center mx-auto mb-4">
<Building2 className="w-8 h-8 text-primary" />
</div>
<h1 className="text-3xl font-bold text-text-main mb-2">
Selecione uma Organização
</h1>
<p className="text-text-muted">
Escolha qual organização você deseja acessar
</p>
</div>
<div className="grid gap-4">
{userMemberships.data?.map((membership) => (
<button
key={membership.organization.id}
onClick={() => handleSelectOrganization(membership.organization.id)}
className="w-full bg-surface hover:bg-surface-hover border border-border/40 rounded-2xl p-6 text-left transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/10 group"
>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-primary/20 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/30 transition-colors">
{membership.organization.imageUrl ? (
<img
src={membership.organization.imageUrl}
alt={membership.organization.name}
className="w-12 h-12 rounded-lg object-contain"
/>
) : (
<Building2 className="w-7 h-7 text-primary" />
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-text-main group-hover:text-primary transition-colors">
{membership.organization.name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-primary/20 text-primary text-xs font-semibold">
{membership.role === 'org:admin' ? 'Administrador' :
membership.role === 'org:member' ? 'Membro' : 'Convidado'}
</span>
<span className="flex items-center gap-1 text-xs text-text-muted">
<Users className="w-3 h-3" />
{membership.organization.membersCount || 0} membros
</span>
</div>
</div>
<div className="text-primary opacity-0 group-hover:opacity-100 transition-opacity">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
</div>
<div className="mt-6 text-center">
<p className="text-sm text-text-muted">
Conectado como <span className="font-semibold">{user?.primaryEmailAddress?.emailAddress}</span>
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useState } from 'react';
import api from '../services/api';
import { Button } from '../components/Button';
import { Plus, Pencil, Trash2, Box, Search } from 'lucide-react';
import { MobileList } from '../components/MobileList';
import { CreatePartModal } from '../components/modals/CreatePartModal';
import type { Part, GeometryType } from '../types';
export const PartsList: React.FC = () => {
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [editItem, setEditItem] = useState<Part | undefined>(undefined);
const [geometryTypes, setGeometryTypes] = useState<GeometryType[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchParts();
fetchGeometryTypes();
}, []);
const fetchParts = async () => {
try {
const response = await api.get('/parts');
setParts(response.data);
} catch (error) {
console.error('Error fetching parts:', error);
} finally {
setLoading(false);
}
};
const fetchGeometryTypes = async () => {
try {
const response = await api.get('/geometry-types');
setGeometryTypes(response.data);
} catch (error) {
console.error('Error fetching geometry types:', error);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Tem certeza que deseja excluir esta peça?')) return;
try {
await api.delete(`/parts/${id}`);
fetchParts();
} catch (error) {
console.error('Error deleting part', error);
}
};
const getLossForType = (type?: string) => {
if (!type) return '--';
const geo = geometryTypes.find(g => g.name === type);
return geo ? `${geo.efficiencyLoss}%` : '--';
};
const filteredParts = parts.filter(p =>
p.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.type && p.type.toLowerCase().includes(searchTerm.toLowerCase()))
);
const columns = [
{
header: 'Peça / Descrição',
accessor: (p: Part) => (
<div className="flex flex-col">
<span className="font-bold text-text-main text-xs uppercase tracking-tight">{p.description}</span>
<span className="text-[10px] text-text-muted font-medium uppercase">{p.type || 'Sem Categoria'}</span>
</div>
)
},
{
header: 'Perdas Estim.(%)',
accessor: (p: Part) => (
<span className="text-orange-500 font-black text-xs">{getLossForType(p.type)}</span>
)
},
{
header: 'Peso (Kg)',
accessor: (p: Part) => (
<span className="text-text-main font-bold text-xs">{p.weight ? `${p.weight} kg` : '--'}</span>
)
},
{
header: 'Área (m²)',
accessor: (p: Part) => (
<span className="text-primary font-black text-xs">{p.area ? `${p.area}` : '--'}</span>
)
},
];
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
<Box className="w-8 h-8" />
</div>
<div>
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Peças & Geometria</h1>
<p className="text-sm text-text-muted font-medium tracking-widest uppercase text-xs">Catálogo global de itens cadastrados</p>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="relative hidden lg:block group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar peças..."
className="h-14 w-64 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
<Plus className="w-5 h-5 mr-2" />
Nova Peça
</Button>
</div>
</div>
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
<MobileList
data={filteredParts}
columns={columns}
keyExtractor={(item) => item.id}
titleAccessor="description"
subtitleAccessor={(item) => `${item.type || 'N/A'} - ${item.weight || 0}kg`}
actionRender={(item) => (
<div className="flex gap-1 justify-end">
<button
onClick={() => { setEditItem(item); setIsModalOpen(true); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
aria-label="Editar peça"
title="Editar peça"
>
<Pencil size={18} />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
aria-label="Excluir peça"
title="Excluir peça"
>
<Trash2 size={18} />
</button>
</div>
)}
/>
{filteredParts.length === 0 && (
<div className="text-center py-24">
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6 border border-border/20">
<Box className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-text-main">Nenhuma peça encontrada</h3>
<p className="mt-2 text-text-muted font-medium">Você ainda não possui peças cadastradas no sistema.</p>
</div>
)}
</div>
<CreatePartModal
isOpen={isModalOpen}
onClose={() => { setIsModalOpen(false); setEditItem(undefined); }}
onSuccess={fetchParts}
initialData={editItem}
/>
</div>
);
};

View File

@@ -0,0 +1,896 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useLocation } from 'react-router-dom';
import api from '../services/api';
import { Button } from '../components/Button';
import { Card } from '../components/Card';
import { ArrowLeft, Layers, PenTool, ClipboardCheck, Activity, Trash2, Pencil, Copy, RefreshCw, Thermometer, Droplets, Sun } from 'lucide-react';
import { clsx } from 'clsx';
import { format } from 'date-fns';
import { useAuth } from '../context/useAuth';
import { useToast } from '../hooks/useToast';
import { CreatePartModal } from '../components/modals/CreatePartModal';
import { CreatePaintingSchemeModal } from '../components/modals/CreatePaintingSchemeModal';
import { CreateControlRecordModal } from '../components/modals/CreateControlRecordModal';
import { CreateInspectionModal } from '../components/modals/CreateInspectionModal';
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
import { CloneSchemeModal } from '../components/modals/CloneSchemeModal';
import { ImportSchemeModal } from '../components/modals/ImportSchemeModal';
import { MobileList } from '../components/MobileList';
import { useNavigate } from 'react-router-dom';
import type { Project, Part, PaintingScheme, Inspection, ApplicationRecord, GeometryType, TechnicalDataSheet } from '../types';
import { AnalysisDashboard } from './AnalysisDashboard';
import * as geometryTypeService from '../services/geometryTypeService';
import { ColorBubble } from '../components/ColorBubble';
export const ProjectDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { isAdmin, isUser, appUser, isGuest } = useAuth();
const { showGuestWarning } = useToast();
const canEditItem = (item: { createdBy?: string }) => {
if (isAdmin()) return true;
if (!isUser() || !appUser) return false;
return item.createdBy === appUser.id;
};
const navigate = useNavigate();
const location = useLocation();
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [isPartModalOpen, setIsPartModalOpen] = useState(false);
const [isSchemeModalOpen, setIsSchemeModalOpen] = useState(false);
const [isControlModalOpen, setIsControlModalOpen] = useState(false);
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [isEditProjectModalOpen, setIsEditProjectModalOpen] = useState(false);
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isExchangeMode, setIsExchangeMode] = useState(false);
interface LocationState {
activeTab?: 'parts' | 'scheme' | 'control' | 'inspection' | 'analysis';
}
const initialTab = (location.state as LocationState)?.activeTab || 'parts';
const [activeTab, setActiveTab] = useState<'parts' | 'scheme' | 'control' | 'inspection' | 'analysis'>(initialTab);
const [editingScheme, setEditingScheme] = useState<PaintingScheme | undefined>(undefined);
const [cloningScheme, setCloningScheme] = useState<PaintingScheme | undefined>(undefined);
const [editingPart, setEditingPart] = useState<Part | undefined>(undefined);
const [editingInspection, setEditingInspection] = useState<Inspection | undefined>(undefined);
const [editingRecord, setEditingRecord] = useState<ApplicationRecord | undefined>(undefined);
const [geometryTypes, setGeometryTypes] = useState<GeometryType[]>([]);
const getLossForType = (type?: string) => {
if (!type) return '--';
const geo = geometryTypes.find(g => g.name === type);
return geo ? `${geo.efficiencyLoss}%` : '--';
};
const handleDeleteScheme = async (schemeId: string) => {
if (isGuest()) {
showGuestWarning();
return;
}
if (!confirm('Tem certeza que deseja excluir este esquema?')) return;
try {
await api.delete(`/painting-schemes/${schemeId}`);
fetchProject();
} catch (error) {
console.error('Error deleting scheme', error);
alert('Erro ao excluir esquema');
}
};
const handleDeletePart = async (partId: string) => {
if (isGuest()) {
showGuestWarning();
return;
}
if (!confirm('Tem certeza que deseja excluir esta peça?')) return;
try {
await api.delete(`/parts/${partId}`);
fetchProject();
} catch (error) {
console.error('Error deleting part', error);
alert('Erro ao excluir peça');
}
};
const handleDeleteInspection = async (id: string) => {
if (isGuest()) {
showGuestWarning();
return;
}
if (!confirm('Tem certeza que deseja excluir esta inspeção?')) return;
try {
await api.delete(`/inspections/${id}`);
fetchProject();
} catch (error) {
console.error('Error deleting inspection', error);
alert('Erro ao excluir inspeção');
}
};
const handleDeleteRecord = async (id: string) => {
if (isGuest()) {
showGuestWarning();
return;
}
if (!confirm('Tem certeza que deseja excluir este registro?')) return;
try {
await api.delete(`/application-records/${id}`);
fetchProject();
} catch (error) {
console.error('Error deleting record', error);
alert('Erro ao excluir registro');
}
};
const handleDeleteProject = async () => {
if (!id) return;
if (isGuest()) {
showGuestWarning();
return;
}
if (!confirm('Tem certeza que deseja excluir este projeto COMPLETO? Todos os dados vinculados serão perdidos permanentemente.')) return;
try {
await api.delete(`/projects/${id}`);
navigate('/');
} catch (error) {
console.error('Error deleting project', error);
alert('Erro ao excluir projeto');
}
};
const openEditScheme = (scheme: PaintingScheme) => {
setEditingScheme(scheme);
setIsSchemeModalOpen(true);
};
const openEditPart = (part: Part) => {
setEditingPart(part);
setIsPartModalOpen(true);
};
const openEditInspection = (insp: Inspection) => {
setEditingInspection(insp);
setIsInspectionModalOpen(true);
};
const openEditRecord = (record: ApplicationRecord) => {
setEditingRecord(record);
setIsControlModalOpen(true);
};
const fetchProject = React.useCallback(async () => {
try {
const response = await api.get(`/projects/${id}`);
setProject(response.data);
} catch (error) {
console.error('Error fetching project:', error);
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
fetchProject();
// Buscar tipos de geometria para as perdas
geometryTypeService.getAllTypes().then(res => setGeometryTypes(res.data)).catch(console.error);
}, [fetchProject]);
if (loading) return <div className="p-8 text-center">Carregando detalhes...</div>;
if (!project) return <div className="p-8 text-center text-error">Projeto não encontrado.</div>;
const tabs: { id: typeof activeTab, label: string, icon: React.ElementType }[] = [
{ id: 'parts', label: 'Geometria & Peças', icon: Layers },
{ id: 'scheme', label: 'Esquema de Pintura', icon: PenTool },
{ id: 'inspection', label: 'Inspeção', icon: ClipboardCheck },
{ id: 'control', label: 'Controle de Aplicação', icon: Activity },
{ id: 'analysis', label: 'Análise de Conformidade', icon: ClipboardCheck },
];
return (
<div className="space-y-8 animate-in fade-in duration-700">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-4">
<Link to="/projects">
<button className="flex items-center text-[10px] font-bold text-text-muted uppercase tracking-[0.2em] hover:text-primary transition-colors group">
<ArrowLeft className="w-3 h-3 mr-2 group-hover:-translate-x-1 transition-transform" />
Voltar aos Projetos
</button>
</Link>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">{project.name}</h1>
<span className="inline-flex items-center px-3 py-1 rounded-full bg-primary/10 text-primary text-[10px] font-black uppercase tracking-widest border border-primary/20 shadow-sm shadow-primary/5">
<Activity className="w-3 h-3 mr-2" />
Em Andamento
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
<div className="w-6 h-6 rounded-full bg-surface-highlight border-2 border-surface flex items-center justify-center text-[10px] font-bold">M</div>
<div className="w-6 h-6 rounded-full bg-primary/20 border-2 border-surface flex items-center justify-center text-[10px] font-bold text-primary">G</div>
</div>
<span className="text-sm text-text-secondary font-medium">{project.client} {project.environment}</span>
</div>
</div>
{isAdmin() && (
<div className="flex gap-3">
<Button variant="secondary" size="sm" onClick={() => setIsEditProjectModalOpen(true)}>
<Pencil className="w-4 h-4 mr-2" /> Editar Obra
</Button>
<Button variant="danger" size="sm" className="bg-error/10 text-error border border-error/20 hover:bg-error transition-all" onClick={handleDeleteProject}>
<Trash2 className="w-4 h-4 mr-2" /> Excluir
</Button>
</div>
)}
</div>
{/* Premium Summary Grid */}
<div className="relative overflow-hidden rounded-3xl bg-surface border border-border/40 shadow-soft p-8">
<div className="absolute top-0 right-0 p-8 opacity-5">
<Activity size={120} strokeWidth={1} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 relative z-10">
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Data de Início</span>
<p className="text-lg font-bold text-text-main">{project.startDate ? format(new Date(project.startDate), 'dd MMM, yyyy') : '-'}</p>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Previsão Término</span>
<p className="text-lg font-bold text-text-main">{project.endDate ? format(new Date(project.endDate), 'dd MMM, yyyy') : '-'}</p>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Peso da Obra (KGF)</span>
<p className="text-lg font-bold text-text-main">
{project.weightKg ? Number(project.weightKg).toLocaleString('pt-BR') : '0'} <span className="text-sm text-text-muted">kg</span>
</p>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Evolução%</span>
<p className="text-lg font-bold text-primary">
{project.weightKg && project.paintedWeight !== undefined
? Math.min(Math.round((project.paintedWeight / project.weightKg) * 100), 100)
: 0}
<span className="text-sm">%</span>
</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-border/20 grid grid-cols-1 md:grid-cols-2 gap-6 relative z-10">
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Localização/Amb.</span>
<p className="text-sm font-medium text-text-secondary">{project.environment || '-'}</p>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Eng. Responsável</span>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
<p className="text-sm font-bold text-text-main">{project.technician || '-'}</p>
</div>
</div>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex items-center justify-between border-b border-border/40 scrollbar-hide overflow-x-auto">
<nav className="flex space-x-10" aria-label="Tabs">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'whitespace-nowrap py-5 px-1 border-b-2 font-bold text-xs uppercase tracking-[0.15em] flex items-center gap-3 transition-all relative',
active
? 'border-primary text-primary'
: 'border-transparent text-text-muted hover:text-text-main hover:border-border'
)}
>
<Icon className={clsx("w-4 h-4", active ? "text-primary" : "text-text-muted")} />
{tab.label}
{active && <span className="absolute bottom-[-1px] left-0 right-0 h-[2px] bg-primary shadow-[0_0_12px_rgba(13,127,242,0.6)]"></span>}
</button>
);
})}
</nav>
</div>
<div className="min-h-[300px]">
{activeTab === 'parts' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<Layers size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Listagem de Geometrias</h2>
<p className="text-xs text-text-muted font-medium">Gerencie as peças e áreas cadastradas para esta obra</p>
</div>
</div>
{isAdmin() && (
<Button size="sm" onClick={() => setIsPartModalOpen(true)}>
<Layers className="w-4 h-4 mr-2" /> Cadastrar Peça
</Button>
)}
</div>
{project.parts?.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
<Layers className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
<p className="text-text-muted font-medium">Nenhuma peça cadastrada neste projeto.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{project.parts?.map((part: Part) => (
<Card key={part.id} className="relative group overflow-hidden border-border/20 shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-500">
<div className="absolute top-0 left-0 w-1.5 h-full bg-primary/20 group-hover:bg-primary transition-colors"></div>
<div className="flex justify-between items-start pl-2">
<div className="space-y-1">
<span className="text-[10px] font-bold text-primary uppercase tracking-widest">{part.type || 'GEOMETRIA'}</span>
<h4 className="text-lg font-bold text-text-main tracking-tight leading-tight">{part.description}</h4>
</div>
{isAdmin() && (
<div className="flex gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all">
<button
onClick={() => openEditPart(part)}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
title="Editar Peça"
aria-label="Editar Peça"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDeletePart(part.id)}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
title="Excluir Peça"
aria-label="Excluir Peça"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
<div className="mt-6 pt-4 border-t border-border/40 grid grid-cols-2 gap-4">
<div className="space-y-0.5">
<span className="text-[9px] font-bold text-text-muted uppercase tracking-wider">Peso Total Estimado</span>
<p className="text-sm font-bold text-text-main">{(part.weight || 0)} kg</p>
</div>
<div className="space-y-0.5 text-right">
<span className="text-[9px] font-bold text-text-muted uppercase tracking-wider">Perdas Estim.(%)</span>
<p className="text-sm font-black text-error">{getLossForType(part.type)}</p>
</div>
</div>
<div className="mt-4 p-3 bg-surface-soft/50 rounded-xl flex items-center justify-between">
<span className="text-[10px] font-bold text-text-secondary uppercase">Área Total Superfície</span>
<span className="text-sm font-black text-primary">
{(part.area || 0).toFixed(2)} m²
</span>
</div>
</Card>
))}
</div>
)}
</div>
)}
{activeTab === 'scheme' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<PenTool size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Esquema de Pintura</h2>
<p className="text-xs text-text-muted font-medium">Defina as tintas, etapas e espessuras requeridas</p>
</div>
</div>
{isAdmin() && (
<div className="flex gap-2">
<Button
onClick={() => { setIsExchangeMode(true); setIsImportModalOpen(true); }}
variant="ghost"
className="text-text-muted hover:text-primary"
title="Trocar ou Importar Esquema"
>
<RefreshCw className="w-5 h-5 mr-1" />
Trocar
</Button>
<Button onClick={() => { setEditingScheme(undefined); setIsSchemeModalOpen(true); }} className="shadow-primary/30">
<PenTool className="w-5 h-5 mr-2" />
Adicionar Demão
</Button>
</div>
)}
</div>
{project.paintingSchemes?.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
<PenTool className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
<p className="text-text-muted font-medium">Nenhum esquema cadastrado neste projeto.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{project.paintingSchemes?.map((scheme: PaintingScheme) => (
<Card key={scheme.id} className="relative group overflow-hidden border-border/20 shadow-sm hover:shadow-xl transition-all duration-500">
<div className="flex justify-between items-start">
<div className="space-y-1">
<span className="px-2 py-0.5 rounded bg-primary text-white text-[9px] font-black uppercase tracking-widest">{scheme.type}</span>
<div className="flex items-center gap-6 mt-2">
<h4 className="text-xl font-bold text-text-main tracking-tight">{scheme.name}</h4>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-text-muted uppercase">Redutor:</span>
<span className="text-sm font-bold text-text-main">
{scheme.thinnerSymbol || (typeof scheme.thinnerId === 'object' ? (scheme.thinnerId as TechnicalDataSheet)?.name : '---')}
</span>
</div>
</div>
</div>
{isAdmin() && (
<div className="flex gap-1">
<button
onClick={() => { setCloningScheme(scheme); setIsCloneModalOpen(true); }}
className="p-2 text-text-muted hover:text-green-500 hover:bg-green-500/10 rounded-xl transition-all"
title="Clonar para outra obra"
aria-label="Clonar para outra obra"
>
<Copy size={16} />
</button>
<button
onClick={() => openEditScheme(scheme)}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
title="Editar Esquema"
aria-label="Editar Esquema"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDeleteScheme(scheme.id)}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
title="Excluir Esquema"
aria-label="Excluir Esquema"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
<div className="mt-8 grid grid-cols-3 gap-6">
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">EPS Requerida</span>
<div className="flex items-baseline gap-1">
<span className="text-lg font-black text-text-main">{scheme.epsMin}-{scheme.epsMax}</span>
<span className="text-[10px] font-bold text-text-muted">μm</span>
</div>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Sólidos Vol.</span>
<p className="text-lg font-black text-text-main">{scheme.solidsVolume}%</p>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-widest block">Cor / Cod.</span>
<div className="flex items-center gap-3">
<p className="text-lg font-black text-text-main truncate" title={scheme.color || '-'}>{scheme.color || '-'}</p>
<ColorBubble colorHex={scheme.colorHex} className="w-10 h-10" />
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t border-border/40 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)] animate-pulse"></div>
<span className="text-[10px] font-black text-text-muted uppercase tracking-widest">Ativo no Sistema</span>
</div>
<span className="text-[10px] font-bold text-text-muted italic">Item verificado NBR 12103</span>
</div>
</Card>
))}
</div>
)}
</div>
)}
{activeTab === 'control' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<Activity size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Controle de Aplicação</h2>
<p className="text-xs text-text-muted font-medium">Registro diário de demãos e consumos</p>
</div>
</div>
{isUser() && (
<Button size="sm" onClick={() => setIsControlModalOpen(true)}>
<Activity className="w-4 h-4 mr-2" /> Novo Registro
</Button>
)}
</div>
{project.applicationRecords?.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
<Activity className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
<p className="text-text-muted font-medium">Nenhum registro de aplicação encontrado.</p>
</div>
) : (
<div className="rounded-3xl border border-border/40 overflow-hidden bg-surface shadow-soft">
<MobileList<ApplicationRecord>
data={project.applicationRecords || []}
keyExtractor={(item) => item.id}
titleAccessor={(item) => `${item.coatStage} - ${item.pieceDescription}`}
subtitleAccessor={(item) => item.date ? format(new Date(item.date), 'dd/MM/yyyy') : '-'}
columns={[
{ header: 'Data', accessor: (item) => item.date ? format(new Date(item.date), 'dd MMM') : '-' },
{
header: 'Etapa/Peça', accessor: (item) => (
<div className="flex flex-col">
<span className="font-bold text-text-main">{item.coatStage}</span>
<span className="text-[10px] text-text-muted font-bold uppercase">{item.pieceDescription}</span>
</div>
)
},
{ header: 'Pintor', accessor: 'operator' },
{
header: 'EPS Seca (μm)', accessor: (item) => (
<span className="px-2 py-1 rounded-lg bg-primary/5 text-primary font-black">
{item.dryThicknessCalc || '-'}
</span>
)
},
]}
actionRender={(item) => canEditItem(item) ? (
<div className="flex gap-1 justify-end">
<button
onClick={(e) => { e.stopPropagation(); openEditRecord(item); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
title="Editar Registro"
>
<Pencil size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteRecord(item.id); }}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
title="Excluir Registro"
>
<Trash2 size={14} />
</button>
</div>
) : null}
/>
</div>
)}
</div>
)}
{activeTab === 'inspection' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between items-center bg-surface-soft/30 p-4 rounded-2xl border border-border/20">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<ClipboardCheck size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-text-main tracking-tight mb-0">Relatórios de Inspeção</h2>
<p className="text-xs text-text-muted font-medium">Verificações de qualidade e medições de espessura final</p>
</div>
</div>
{isUser() && (
<Button size="sm" onClick={() => setIsInspectionModalOpen(true)}>
<ClipboardCheck className="w-4 h-4 mr-2" /> Nova Inspeção
</Button>
)}
</div>
{project.inspections?.length === 0 ? (
<div className="py-20 text-center border-2 border-dashed border-border/40 rounded-3xl">
<ClipboardCheck className="w-12 h-12 text-text-muted/20 mx-auto mb-4" />
<p className="text-text-muted font-medium">Nenhuma inspeção registrada.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{project.inspections?.map((insp: Inspection) => (
<Card key={insp.id} className="relative group overflow-hidden border-border/20 shadow-sm hover:shadow-xl transition-all duration-500">
<div className="absolute top-0 right-0 p-4">
<div className={clsx(
"px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border shadow-sm",
insp.appearance === 'approved'
? 'bg-success/10 text-success border-success/20'
: insp.appearance === 'rejected'
? 'bg-error/10 text-error border-error/20'
: 'bg-warning/10 text-warning border-warning/20'
)}>
{insp.appearance === 'approved' ? 'Aprovado' : insp.appearance === 'rejected' ? 'Reprovado' : 'Ressalvas'}
</div>
</div>
<div className="flex justify-between items-start">
<div className="space-y-1">
<span className="text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">
{insp.date ? format(new Date(insp.date), 'dd/MM/yyyy') : '-'}
</span>
<h4 className="text-lg font-bold text-text-main tracking-tight leading-tight pt-1">
{insp.pieceDescription}
</h4>
<p className="text-xs text-text-muted font-medium">Inspector: {insp.inspector}</p>
</div>
{canEditItem(insp) && (
<div className="flex gap-1 mt-8">
<button
onClick={() => openEditInspection(insp)}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
title="Editar Inspeção"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDeleteInspection(insp.id)}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
title="Excluir Inspeção"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
{(insp.stockItemId || insp.treatmentType || (insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0) || (project.weightKg && insp.weightKg)) && (
<div className="mt-6 px-1 flex flex-wrap md:flex-nowrap items-center gap-6 md:gap-10 border-t border-border/10 pt-6">
{/* Evolution Pie */}
{project.weightKg && insp.weightKg && (
<div className="flex flex-col items-center shrink-0">
<span className="text-[9px] font-bold text-text-muted uppercase tracking-[0.2em] block mb-2 whitespace-nowrap">Peso / Evolução</span>
<div className="relative w-16 h-16 flex items-center justify-center bg-surface-soft/40 dark:bg-black/10 rounded-full border border-border/10 shadow-sm">
<svg viewBox="0 0 36 36" className="w-[85%] h-[85%] -rotate-90">
<circle cx="18" cy="18" r="15.9155" fill="transparent" stroke="currentColor" strokeWidth="3" className="text-border/20" />
<circle
cx="18" cy="18" r="15.9155" fill="transparent" stroke="currentColor" strokeWidth="3"
strokeDasharray={`${Math.min(Math.round((insp.weightKg / project.weightKg) * 100), 100)} 100`}
className="text-primary transition-all duration-1000"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-base font-black text-text-main">
{Math.min(Math.round((insp.weightKg / project.weightKg) * 100), 100)}%
</span>
</div>
<span className="text-[10px] font-black text-primary mt-1">{insp.weightKg} kg</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 flex-1 h-full items-center md:border-l border-border/20 md:pl-8">
{typeof insp.stockItemId === 'object' && insp.stockItemId && (
<div className="col-span-1">
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest block mb-1">Tinta Utilizada</span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary/40"></div>
<span className="text-xs font-bold text-text-main truncate max-w-[180px]" title={(insp.stockItemId as any).dataSheetId?.name}>
{(insp.stockItemId as any).dataSheetId?.name || 'Item sem nome'}
</span>
</div>
<div className="pl-3.5">
<span className="text-[10px] text-text-muted font-black uppercase tracking-wider bg-surface-soft/50 py-0.5 px-1 rounded border border-border/5">
Lote: {(insp.stockItemId as any).batchNumber}
</span>
</div>
</div>
</div>
)}
{(insp.treatmentType || (insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0)) && (
<div className="col-span-1 flex gap-8">
{insp.treatmentType && (
<div>
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest block mb-0.5">Tipo Jato</span>
<span className="text-[11px] font-bold text-text-main bg-stone-100 px-1.5 py-0.5 rounded border border-stone-200 shadow-sm">
{insp.treatmentType === 'dry_abrasive_blasting' ? 'Seco' :
insp.treatmentType === 'water_jetting' ? 'Hidrojato' :
insp.treatmentType === 'mechanical_cleaning' ? 'Mecânica' :
insp.treatmentType === 'manual_cleaning' ? 'Manual' : 'Outro'}
</span>
</div>
)}
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0 && (
<div>
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest block mb-0.5">Rug. Média</span>
<span className="text-xs font-black text-amber-600">
{(insp.roughnessReadings.filter((p): p is number => p !== null).reduce((a, b) => a + b, 0) / insp.roughnessReadings.filter(p => p !== null).length).toFixed(0)} <small className="text-[9px] font-medium opacity-70">μm</small>
</span>
</div>
)}
</div>
)}
</div>
</div>
)}
{(insp.temperature || insp.relativeHumidity) && (
<div className="mt-4 px-1 flex items-center gap-4 text-text-muted border-t border-border/10 pt-3">
{insp.temperature && (
<div className="flex items-center gap-1.5">
<Thermometer className="w-3 h-3 text-orange-500" />
<span className="text-[11px] font-bold text-text-secondary">{insp.temperature}°C</span>
</div>
)}
{insp.relativeHumidity && (
<div className="flex items-center gap-1.5">
<Droplets className="w-3 h-3 text-blue-500" />
<span className="text-[11px] font-bold text-text-secondary">{insp.relativeHumidity}% UR</span>
</div>
)}
{insp.partTemperature && (
<div className="flex items-center gap-1.5">
<Thermometer className="w-3 h-3 text-red-500" />
<span className="text-[11px] font-bold text-text-secondary">{insp.partTemperature}°C (Peça)</span>
</div>
)}
{insp.period && (
<div className="flex items-center gap-1.5 ml-auto">
<Sun className="w-3 h-3 text-amber-500" />
<span className="text-[9px] font-black uppercase tracking-tighter text-text-muted">{insp.period === 'morning' ? 'Manhã' : insp.period === 'afternoon' ? 'Tarde' : 'Noite'}</span>
</div>
)}
</div>
)}
<div className="mt-6 p-4 bg-surface-soft/50 rounded-2xl flex items-center justify-between border border-border/20">
{insp.type === 'painting' ? (
<>
<div className="flex flex-col">
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest">EPS Média</span>
{insp.epsPoints && insp.epsPoints.filter(p => p !== null).length > 0 ? (
<span className="text-xl font-black text-primary">
{(insp.epsPoints.filter((p): p is number => p !== null).reduce((a, b) => a + b, 0) / insp.epsPoints.filter(p => p !== null).length).toFixed(0)}
<span className="text-xs ml-1">μm</span>
</span>
) : (
<span className="text-sm font-bold text-text-muted">Sem dados</span>
)}
</div>
<div className="flex gap-4 border-l border-border/40 pl-4">
<div className="flex flex-col">
<span className="text-[9px] font-bold text-text-muted uppercase">Min</span>
<span className="text-sm font-bold text-text-main">
{insp.epsPoints && insp.epsPoints.filter(p => p !== null).length > 0
? Math.min(...insp.epsPoints.filter((p): p is number => p !== null)).toFixed(0)
: '-'}
</span>
</div>
<div className="flex flex-col">
<span className="text-[9px] font-bold text-text-muted uppercase">Max</span>
<span className="text-sm font-bold text-text-main">
{insp.epsPoints && insp.epsPoints.filter(p => p !== null).length > 0
? Math.max(...insp.epsPoints.filter((p): p is number => p !== null)).toFixed(0)
: '-'}
</span>
</div>
</div>
</>
) : (
<>
<div className="flex flex-col">
<span className="text-[9px] font-bold text-text-muted uppercase tracking-widest">Rugosidade Média</span>
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0 ? (
<span className="text-xl font-black text-amber-600">
{(insp.roughnessReadings.filter((p): p is number => p !== null).reduce((a, b) => a + b, 0) / insp.roughnessReadings.filter(p => p !== null).length).toFixed(0)}
<span className="text-xs ml-1">μm</span>
</span>
) : (
<span className="text-sm font-bold text-text-muted">Sem dados</span>
)}
</div>
<div className="flex gap-4 border-l border-border/40 pl-4">
<div className="flex flex-col">
<span className="text-[9px] font-bold text-text-muted uppercase">Min</span>
<span className="text-sm font-bold text-text-main">
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0
? Math.min(...insp.roughnessReadings.filter((p): p is number => p !== null)).toFixed(0)
: '-'}
</span>
</div>
<div className="flex flex-col">
<span className="text-[9px] font-bold text-text-muted uppercase">Max</span>
<span className="text-sm font-bold text-text-main">
{insp.roughnessReadings && insp.roughnessReadings.filter(p => p !== null).length > 0
? Math.max(...insp.roughnessReadings.filter((p): p is number => p !== null)).toFixed(0)
: '-'}
</span>
</div>
</div>
</>
)}
</div>
</Card>
))}
</div>
)}
</div>
)}
{activeTab === 'analysis' && id && (
<AnalysisDashboard projectId={id} />
)}
{id && (
<>
<CreatePartModal
isOpen={isPartModalOpen}
onClose={() => {
setIsPartModalOpen(false);
setEditingPart(undefined);
}}
onSuccess={fetchProject}
projectId={id}
initialData={editingPart}
/>
<CreatePaintingSchemeModal
isOpen={isSchemeModalOpen}
onClose={() => {
setIsSchemeModalOpen(false);
setEditingScheme(undefined);
}}
onSuccess={fetchProject}
projectId={id}
initialData={editingScheme}
/>
<CreateControlRecordModal
isOpen={isControlModalOpen}
onClose={() => {
setIsControlModalOpen(false);
setEditingRecord(undefined);
}}
onSuccess={fetchProject}
projectId={id}
initialData={editingRecord}
availableParts={project.parts || []}
existingRecords={project.applicationRecords || []}
availableBatches={project.inspections || []}
/>
<CreateInspectionModal
isOpen={isInspectionModalOpen}
onClose={() => {
setIsInspectionModalOpen(false);
setEditingInspection(undefined);
}}
onSuccess={fetchProject}
projectId={id}
initialData={editingInspection}
existingInspections={project.inspections || []}
/>
<CreateProjectModal
isOpen={isEditProjectModalOpen}
onClose={() => setIsEditProjectModalOpen(false)}
onSuccess={fetchProject}
initialData={project}
/>
<CloneSchemeModal
isOpen={isCloneModalOpen}
onClose={() => { setIsCloneModalOpen(false); setCloningScheme(undefined); }}
onSuccess={fetchProject} // Actually we don't need to fetchProject here as the cloned scheme is in ANOTHER project. But maybe good for hygiene.
schemeToClone={cloningScheme}
/>
<ImportSchemeModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
onSuccess={fetchProject}
targetProjectId={project.id}
isExchangeMode={isExchangeMode}
hasInspections={(project.inspections && project.inspections.length > 0) || (project.applicationRecords && project.applicationRecords.length > 0)}
/>
</>
)}
</div>
</div >
);
};

View File

@@ -0,0 +1,584 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../services/api';
import { Button } from '../components/Button';
import { Plus, Search, Pencil, Trash2, Activity, Box, CheckCircle2, History, Printer } from 'lucide-react';
import { format } from 'date-fns';
import { useAuth } from '../context/useAuth';
import { useToast } from '../hooks/useToast';
import { MobileList } from '../components/MobileList';
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
import { useOrganization } from '@clerk/clerk-react';
import { useSystemSettings } from '../context/SystemSettingsContext';
import { Modal } from '../components/Modal';
import { ConfirmModal } from '../components/ConfirmModal';
import { ColorBubble } from '../components/ColorBubble';
import type { Project, Inspection } from '../types';
import { AnalyticalReport } from '../components/reports/AnalyticalReport';
import { GeneralProjectReport } from '../components/reports/GeneralProjectReport';
export const ProjectList: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [inspections, setInspections] = useState<Inspection[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<Project | undefined>(undefined);
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
description: string;
type: 'danger' | 'warning' | 'info';
onConfirm: () => void;
}>({
isOpen: false,
title: '',
description: '',
type: 'info',
onConfirm: () => { }
});
const [statsModal, setStatsModal] = useState<{ isOpen: boolean; title: string; type: 'alerts' | 'inspections' | null }>({
isOpen: false,
title: '',
type: null
});
const [searchTerm, setSearchTerm] = useState('');
const [viewStatus, setViewStatus] = useState<'active' | 'archived'>('active');
const [printingProject, setPrintingProject] = useState<Project | null>(null);
const [isPrinting, setIsPrinting] = useState(false);
const [isPrintingGeneral, setIsPrintingGeneral] = useState(false);
const navigate = useNavigate();
const { appUser } = useAuth();
const { showToast } = useToast();
const { organization } = useOrganization();
const { settings } = useSystemSettings();
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
const fetchProjects = useCallback(async () => {
try {
setLoading(true);
const [projRes, inspRes] = await Promise.all([
api.get(`/projects?status=${viewStatus}`),
api.get('/inspections')
]);
setProjects(projRes.data);
setInspections(inspRes.data);
} catch (error) {
console.error('Error fetching data:', error);
showToast('Não foi possível carregar os dados.', 'error');
} finally {
setLoading(false);
}
}, [viewStatus, showToast]);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
const handleEdit = (project: Project) => {
if (viewStatus === 'archived') return;
setSelectedProject(project);
setIsModalOpen(true);
};
const handleDelete = async (id: string) => {
const project = projects.find(p => p.id === id);
setConfirmModal({
isOpen: true,
title: 'Excluir Projeto',
description: `Tem certeza que deseja excluir o projeto "${project?.name}"? Todos os dados vinculados serão perdidos permanentemente.`,
type: 'danger',
onConfirm: async () => {
try {
await api.delete(`/projects/${id}`);
fetchProjects();
showToast('O projeto foi removido com sucesso.', 'success');
} catch (error) {
console.error('Error deleting project:', error);
showToast('Não foi possível excluir o projeto.', 'error');
}
setConfirmModal(prev => ({ ...prev, isOpen: false }));
}
});
};
const handlePrint = async (projectId: string) => {
try {
setIsPrinting(true);
const response = await api.get(`/projects/${projectId}`);
setPrintingProject(response.data);
// Wait for state to update and layout to render
setTimeout(() => {
window.print();
setIsPrinting(false);
setPrintingProject(null);
}, 500);
} catch (error) {
console.error('Error fetching project for print:', error);
showToast('Erro ao gerar relatório.', 'error');
setIsPrinting(false);
}
};
const handlePrintGeneral = () => {
setIsPrintingGeneral(true);
setTimeout(() => {
window.print();
setIsPrintingGeneral(false);
}, 500);
};
const handleArchive = async (project: Project) => {
const isArchiving = viewStatus === 'active';
setConfirmModal({
isOpen: true,
title: isArchiving ? 'Concluir Obras' : 'Reativar Projeto',
description: `Deseja ${isArchiving ? 'arquivar' : 'reativar'} o projeto "${project.name}"?`,
type: 'info',
onConfirm: async () => {
try {
await api.patch(`/projects/${project.id}/archive`);
showToast(`O projeto ${project.name} foi movido com sucesso.`, 'success');
fetchProjects();
} catch (error) {
console.error('Error archiving project:', error);
showToast('Não foi possível alterar o status do projeto.', 'error');
}
setConfirmModal(prev => ({ ...prev, isOpen: false }));
}
});
};
const filteredProjects = projects.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.client.toLowerCase().includes(searchTerm.toLowerCase())
);
const columns = [
{
header: 'Obra / Projeto',
accessor: (item: Project) => (
<div
className="flex flex-col cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => navigate(`/project/${item.id}`)}
>
<span className="text-text-main font-black text-sm uppercase leading-tight line-clamp-1">{item.name}</span>
<span className="text-[10px] text-text-muted font-bold uppercase tracking-wider">{item.client}</span>
<span className="text-[9px] text-text-secondary font-bold tracking-tight">
Gestor: <span className="text-text-main">{item.technician || 'Não informado'}</span>
</span>
</div>
),
className: 'min-w-[150px]'
},
{
header: 'Evolução',
accessor: (item: Project) => {
const projectInspections = inspections.filter(i => i.projectId === item.id);
const sumWeight = projectInspections.reduce((acc, curr) => acc + (curr.weightKg || 0), 0);
const totalWeight = item.weightKg || 0;
const percentage = totalWeight > 0 ? Math.min(Math.round((sumWeight / totalWeight) * 100), 100) : 0;
return (
<div className="flex items-center justify-center">
<div className="relative w-10 h-10 flex items-center justify-center">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
<circle
cx="18"
cy="18"
r="15.9155"
fill="transparent"
stroke="currentColor"
strokeWidth="3"
className="text-border/20"
/>
<circle
cx="18"
cy="18"
r="15.9155"
fill="transparent"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${percentage} 100`}
className="text-primary transition-all duration-1000"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-black text-text-main">
{percentage}%
</span>
</div>
</div>
);
},
className: 'hidden sm:table-cell w-[80px]'
},
{
header: 'Cronograma',
accessor: (item: Project) => (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-text-muted font-black uppercase w-10">Início:</span>
<span className="bg-orange-500/10 text-orange-500 px-1.5 py-0.5 rounded text-[10px] font-black">
{item.startDate ? format(new Date(item.startDate), 'dd/MM/yy') : '--/--/--'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-text-muted font-black uppercase w-10">Término:</span>
<span className="bg-orange-950/20 text-orange-400 px-1.5 py-0.5 rounded text-[10px] font-black border border-orange-500/10">
{item.endDate ? format(new Date(item.endDate), 'dd/MM/yy') : '--/--/--'}
</span>
</div>
</div>
),
className: 'hidden md:table-cell w-[110px]'
},
{
header: 'Peso(kgf)',
accessor: (item: Project) => (
<div className="flex flex-col items-center">
<span className="text-text-main font-black text-sm">{item.weightKg ? item.weightKg.toLocaleString('pt-BR') : '0'}</span>
<span className="text-[8px] text-text-muted font-bold uppercase tracking-tighter">Est. Total</span>
</div>
),
className: 'w-[90px]'
},
{
header: 'Tinta',
accessor: (item: Project) => {
const schemes = item.paintingSchemes || [];
if (schemes.length === 0) return <span className="text-text-muted font-bold text-xs">---</span>;
return (
<div className="flex flex-col gap-1.5 py-1">
{schemes.slice(0, 2).map((scheme, idx) => (
<div key={idx} className="flex flex-col max-w-[140px]">
<span className="text-text-main font-bold text-[10px] truncate uppercase leading-none" title={scheme.name}>
{scheme.name}
</span>
<span className="text-[7px] text-text-muted font-black uppercase tracking-tighter opacity-70">
{scheme.coat || 'Demão'}
</span>
</div>
))}
{schemes.length > 2 && (
<span className="text-[7px] text-primary font-black uppercase">+ {schemes.length - 2} demãos</span>
)}
</div>
);
},
className: 'hidden xl:table-cell w-[130px]'
},
{
header: 'Cor',
accessor: (item: Project) => {
const schemes = item.paintingSchemes || [];
if (schemes.length === 0) return <div className="w-4 h-4 rounded-full border border-border/20 opacity-20 border-dashed" />;
return (
<div className="flex flex-col gap-1.5 py-1">
{schemes.slice(0, 2).map((scheme, idx) => (
<div key={idx} className="flex items-center gap-2 h-[18px]">
<span className="text-[9px] text-text-secondary font-bold uppercase truncate max-w-[60px]" title={scheme.color}>
{scheme.color || '-'}
</span>
<ColorBubble colorHex={scheme.colorHex} className="w-3.5 h-3.5" />
</div>
))}
</div>
);
},
className: 'hidden xl:table-cell w-[80px]'
},
{
header: 'Ações',
accessor: (item: Project) => (
<div className="flex items-center justify-end gap-1.5 min-w-[120px]">
<button
onClick={(e) => { e.stopPropagation(); handlePrint(item.id); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors flex items-center justify-center"
title="Gerar Relatório Analítico"
disabled={isPrinting}
>
<Printer size={18} />
</button>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); handleArchive(item); }}
className={`p-2 rounded-lg transition-colors ${viewStatus === 'active'
? 'text-green-500 hover:bg-green-500/10'
: 'text-indigo-500 hover:bg-indigo-500/10'
}`}
title={viewStatus === 'active' ? 'Concluir e Arquivar' : 'Reativar Projeto'}
>
{viewStatus === 'active' ? <CheckCircle2 size={16} /> : <History size={16} />}
</button>
)}
{isAdmin && viewStatus === 'active' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Editar"
>
<Pencil size={18} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(item.id); }}
className="p-2 text-text-muted hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
title="Excluir"
>
<Trash2 size={18} />
</button>
</>
)}
</div>
)
}
];
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-sm">
<Activity className="w-6 h-6" />
</div>
<div>
<h1 className="text-3xl md:text-4xl font-black text-text-main tracking-tight mb-0">
{viewStatus === 'active' ? 'Gestão de Obras' : 'Obras Arquivadas'}
</h1>
<p className="text-sm text-text-muted font-medium tracking-wide">
{viewStatus === 'active'
? 'Monitoramento e controle de esquemas industriais'
: 'Histórico de projetos concluídos e arquivados'}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="secondary"
onClick={() => setViewStatus(viewStatus === 'active' ? 'archived' : 'active')}
className={`shadow-sm ${viewStatus === 'archived' ? 'bg-indigo-500/10 text-indigo-500 border-indigo-500/20' : ''}`}
>
{viewStatus === 'active' ? <Box className="w-5 h-5 mr-2" /> : <Activity className="w-5 h-5 mr-2" />}
{viewStatus === 'active' ? 'Ver Arquivados' : 'Ver Ativos'}
</Button>
<div className="relative hidden lg:block group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar obras..."
className="h-12 w-48 bg-surface-soft border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button
variant="secondary"
onClick={handlePrintGeneral}
className="shadow-sm"
disabled={isPrintingGeneral || projects.length === 0}
>
<Printer className="w-5 h-5 mr-2" />
Relatório Geral
</Button>
{isAdmin && viewStatus === 'active' && (
<Button onClick={() => { setSelectedProject(undefined); setIsModalOpen(true); }} size="md" className="shadow-primary/30">
<Plus className="w-5 h-5 mr-2" />
Novo Projeto
</Button>
)}
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
label: viewStatus === 'active' ? 'Projetos Ativos' : 'Projetos Arquivados',
value: projects.length,
color: viewStatus === 'active' ? 'text-primary' : 'text-indigo-500',
bg: 'bg-primary/5',
onClick: () => { },
interactive: false
},
{
label: 'Inspeções Realizadas',
value: inspections.length,
color: 'text-success',
bg: 'bg-success/5',
onClick: () => setStatsModal({ isOpen: true, title: 'Resumo de Inspeções', type: 'inspections' }),
interactive: true
},
{
label: 'Conformidade',
value: inspections.length > 0
? `${((inspections.filter(i => i.appearance !== 'rejected').length / inspections.length) * 100).toFixed(1)}%`
: '100%',
color: 'text-blue-500',
bg: 'bg-blue-500/5',
onClick: () => { },
interactive: false
},
{
label: 'Alertas Pendentes',
value: inspections.filter(i => i.appearance === 'rejected' || i.defects).length,
color: 'text-error',
bg: 'bg-error/5',
onClick: () => setStatsModal({ isOpen: true, title: 'Resumo de Alertas', type: 'alerts' }),
interactive: true
}
].map((stat, i) => (
<div
key={i}
onClick={stat.onClick}
className={`p-6 bg-surface border border-border/40 rounded-2xl shadow-sm group transition-all ${stat.interactive ? 'cursor-pointer hover:border-primary/40 hover:shadow-md active:scale-95' : ''}`}
>
<span className="text-[10px] font-black text-text-muted uppercase tracking-[0.2em]">{stat.label}</span>
<div className="mt-2 flex items-baseline gap-2">
<span className={`text-3xl font-black ${stat.color}`}>{stat.value}</span>
<span className="text-[10px] text-text-muted font-bold">TOTAL</span>
</div>
</div>
))}
</div>
{/* Project Table/List */}
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
<MobileList
data={filteredProjects}
columns={columns}
keyExtractor={(item) => item.id}
onItemClick={(item) => navigate(`/project/${item.id}`)}
titleAccessor="name"
subtitleAccessor="client"
/>
{projects.length === 0 && (
<div className="text-center py-24">
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6">
{viewStatus === 'active' ? <Search className="w-10 h-10" /> : <Box className="w-10 h-10" />}
</div>
<h3 className="text-xl font-bold text-text-main">
{viewStatus === 'active' ? 'Nenhuma obra encontrada' : 'Nenhuma obra arquivada'}
</h3>
<p className="mt-2 text-text-muted font-medium">
{viewStatus === 'active'
? 'Inicie o monitoramento criando seu primeiro projeto.'
: 'Ainda não existem projetos concluídos no histórico.'}
</p>
</div>
)}
</div>
<CreateProjectModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setSelectedProject(undefined);
}}
onSuccess={fetchProjects}
initialData={selectedProject}
/>
<Modal
isOpen={statsModal.isOpen}
onClose={() => setStatsModal({ ...statsModal, isOpen: false })}
title={statsModal.title}
>
<div className="space-y-4">
{statsModal.type === 'alerts' && (
inspections.filter(i => i.appearance === 'rejected' || i.defects).map(insp => {
const project = projects.find(p => p.id === insp.projectId);
return (
<div
key={insp.id}
className="p-4 rounded-xl bg-error/5 border border-error/20 cursor-pointer hover:bg-error/10 transition-all active:scale-[0.98]"
onClick={() => navigate(`/project/${insp.projectId}`, { state: { activeTab: 'inspection' } })}
>
<div className="flex justify-between items-start mb-2">
<span className="font-bold text-text-main text-sm uppercase">{project?.name || 'Projeto Desconhecido'}</span>
<span className="text-[10px] font-black text-error border border-error/30 px-1 rounded uppercase">CRÍTICO</span>
</div>
<p className="text-sm text-text-secondary font-medium italic">"{insp.defects || 'Defeito não especificado'}"</p>
<div className="mt-3 flex justify-between items-center text-[10px] text-text-muted font-bold uppercase">
<span>Inspetor: {insp.inspector || '--'}</span>
<span>Data: {insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</span>
</div>
</div>
);
})
)}
{statsModal.type === 'inspections' && (
inspections.slice(0, 10).map(insp => {
const project = projects.find(p => p.id === insp.projectId);
return (
<div
key={insp.id}
className="p-4 rounded-xl bg-surface-soft border border-border/40 flex justify-between items-center cursor-pointer hover:bg-surface-hover transition-all active:scale-[0.98]"
onClick={() => navigate(`/project/${insp.projectId}`, { state: { activeTab: 'inspection' } })}
>
<div>
<span className="block font-bold text-text-main text-xs uppercase">{project?.name || '---'}</span>
<span className="text-[10px] text-text-muted font-medium uppercase">{insp.pieceDescription || 'Geral'}</span>
</div>
<div className="text-right">
<span className={`text-[10px] font-black px-2 py-0.5 rounded uppercase ${insp.appearance === 'rejected' ? 'bg-error/10 text-error' : 'bg-success/10 text-success'}`}>
{insp.appearance === 'rejected' ? 'REJEITADO' : 'APROVADO'}
</span>
<span className="block text-[10px] text-text-muted mt-1">{insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</span>
</div>
</div>
);
})
)}
</div>
</Modal>
<ConfirmModal
isOpen={confirmModal.isOpen}
onClose={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
onConfirm={confirmModal.onConfirm}
title={confirmModal.title}
description={confirmModal.description}
type={confirmModal.type}
/>
{printingProject && (
<AnalyticalReport project={printingProject} logoUrl={logoUrl} />
)}
{isPrintingGeneral && (
<GeneralProjectReport
projects={filteredProjects}
inspections={inspections}
logoUrl={logoUrl}
title={viewStatus === 'active' ? 'Relatório de Obras Ativas' : 'Relatório de Obras Arquivadas'}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,210 @@
import React, { useEffect, useState } from 'react';
import api from '../services/api';
import { Button } from '../components/Button';
import { Plus, Pencil, Trash2, Layers, Search, Copy } from 'lucide-react';
import { MobileList } from '../components/MobileList';
import { CreatePaintingSchemeModal } from '../components/modals/CreatePaintingSchemeModal';
import { CloneSchemeModal } from '../components/modals/CloneSchemeModal';
import type { PaintingScheme } from '../types';
import { useAuth } from '../context/useAuth';
export const SchemesList: React.FC = () => {
const [schemes, setSchemes] = useState<PaintingScheme[]>([]);
const [loading, setLoading] = useState(true);
const [editItem, setEditItem] = useState<PaintingScheme | undefined>(undefined);
const [cloneItem, setCloneItem] = useState<PaintingScheme | undefined>(undefined);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const { appUser } = useAuth();
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
useEffect(() => {
fetchSchemes();
}, []);
const fetchSchemes = async () => {
try {
const response = await api.get('/painting-schemes');
console.log("Fetched schemes (JSON):", JSON.stringify(response.data, null, 2));
setSchemes(response.data);
} catch (error) {
console.error('Error fetching schemes:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Tem certeza que deseja excluir este esquema?')) return;
try {
await api.delete(`/painting-schemes/${id}`);
fetchSchemes();
} catch (error) {
console.error('Error deleting scheme', error);
}
};
const filteredSchemes = schemes.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(s.type && s.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
(s.manufacturer && s.manufacturer.toLowerCase().includes(searchTerm.toLowerCase()))
);
const columns = [
{
header: 'Esquema / Produto',
accessor: (s: PaintingScheme) => (
<div className="flex flex-col">
<span className="font-bold text-text-main text-xs uppercase tracking-tight">{s.name}</span>
<span className="text-[10px] text-text-muted font-bold uppercase">{s.manufacturer || '---'}</span>
</div>
)
},
{
header: 'Tipo',
accessor: (s: PaintingScheme) => (
<span className="text-text-secondary font-medium text-xs uppercase">{s.type || '--'}</span>
)
},
{
header: 'Demão',
accessor: (s: PaintingScheme) => (
<span className="text-text-secondary font-medium text-xs uppercase">{s.coat || '--'}</span>
)
},
{
header: 'Cor',
accessor: (s: PaintingScheme) => (
<span className="text-text-secondary font-medium text-xs uppercase">{s.color || '--'}</span>
)
},
{
header: 'EPS (Min)',
accessor: (s: PaintingScheme) => (
<span className="text-text-secondary font-medium text-xs">{s.epsMin ? `${s.epsMin} µm` : '--'}</span>
)
},
{
header: 'SV%',
accessor: (s: PaintingScheme) => (
<span className="text-text-secondary font-medium text-xs">{s.solidsVolume ? `${s.solidsVolume}%` : '--'}</span>
)
},
{
header: 'Rendimento',
accessor: (s: PaintingScheme) => (
<span className="text-primary font-black text-xs">{s.yieldTheoretical ? `${s.yieldTheoretical} m²/L` : '--'}</span>
)
},
];
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center text-primary shadow-lg shadow-primary/5">
<Layers className="w-8 h-8" />
</div>
<div>
<h1 className="text-3xl md:text-5xl font-black text-text-main tracking-tight mb-0">Esquemas de Pintura</h1>
<p className="text-sm text-text-muted font-medium tracking-widest uppercase text-xs">Especificações e rendimentos teóricos</p>
</div>
</div>
</div>
<div className="flex gap-4">
<div className="relative hidden lg:block group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar esquemas..."
className="h-14 w-64 bg-surface border border-border/40 rounded-2xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{isAdmin && (
<Button onClick={() => { setEditItem(undefined); setIsModalOpen(true); }} size="lg" className="shadow-primary/30 h-14">
<Plus className="w-5 h-5 mr-2" />
Novo Esquema
</Button>
)}
</div>
</div>
<div className="bg-surface rounded-[32px] border border-border/40 shadow-soft p-2">
<MobileList
data={filteredSchemes}
columns={columns}
keyExtractor={(item) => item.id}
titleAccessor="name"
subtitleAccessor={(item) => `${item.manufacturer || ''} ${item.type || ''}`}
actionRender={(item) => isAdmin ? (
<div className="flex gap-1 justify-end">
<button
onClick={() => { setCloneItem(item); setIsCloneModalOpen(true); }}
className="p-2 text-text-muted hover:text-green-500 hover:bg-green-500/10 rounded-xl transition-all"
aria-label="Clonar esquema"
title="Clonar para outra obra"
>
<Copy size={18} />
</button>
<button
onClick={() => { setEditItem(item); setIsModalOpen(true); }}
className="p-2 text-text-muted hover:text-primary hover:bg-primary/5 rounded-xl transition-all"
aria-label="Editar esquema"
title="Editar esquema"
>
<Pencil size={18} />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 text-text-muted hover:text-error hover:bg-error/5 rounded-xl transition-all"
aria-label="Excluir esquema"
title="Excluir esquema"
>
<Trash2 size={18} />
</button>
</div>
) : null}
/>
{filteredSchemes.length === 0 && (
<div className="text-center py-24">
<div className="mx-auto h-20 w-20 bg-surface-soft rounded-full flex items-center justify-center text-text-muted/20 mb-6 border border-border/20">
<Layers className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-text-main">Nenhum esquema encontrado</h3>
<p className="mt-2 text-text-muted font-medium">Cadastre os sistemas de pintura para utilizá-los nas obras.</p>
</div>
)}
</div>
<CreatePaintingSchemeModal
isOpen={isModalOpen}
onClose={() => { setIsModalOpen(false); setEditItem(undefined); }}
onSuccess={fetchSchemes}
initialData={editItem}
/>
<CloneSchemeModal
isOpen={isCloneModalOpen}
onClose={() => { setIsCloneModalOpen(false); setCloneItem(undefined); }}
onSuccess={fetchSchemes}
schemeToClone={cloneItem}
/>
</div>
);
};

View File

@@ -0,0 +1,442 @@
import React, { useState, useEffect } from 'react';
import { Package, Plus, Search, ArrowDown, Edit, Trash2, History, Printer, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
import { stockService, type StockItem, type StockMovement } from '../services/stockService';
import { StockModal } from '../components/modals/StockModal';
import { StockOutModal } from '../components/modals/StockOutModal';
import { StockHistoryModal } from '../components/modals/StockHistoryModal';
import { StockInventoryReport } from '../components/reports/StockInventoryReport';
import { DiluentListModal } from '../components/modals/DiluentListModal';
import { useAuth } from '../context/useAuth';
import { useOrganization } from '@clerk/clerk-react';
import { useSystemSettings } from '../context/SystemSettingsContext';
export const StockDashboard: React.FC = () => {
// ... rest of component
const { isAdmin } = useAuth();
const { organization } = useOrganization();
const { settings } = useSystemSettings();
const [items, setItems] = useState<StockItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [showAddModal, setShowAddModal] = useState(false);
const [showOutModal, setShowOutModal] = useState(false);
const [showHistoryModal, setShowHistoryModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<StockItem | null>(null);
const [isPrintingInventory, setIsPrintingInventory] = useState(false);
const [allMovements, setAllMovements] = useState<Map<string, StockMovement[]>>(new Map());
const [activeTab, setActiveTab] = useState<'PAINT' | 'THINNER'>('PAINT');
const [showDiluentModal, setShowDiluentModal] = useState(false);
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
const fetchItems = async () => {
setLoading(true);
try {
const data = await stockService.getAll();
setItems(data);
} catch (error) {
console.error('Error fetching stock:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchItems();
}, []);
const handleEdit = (item: StockItem) => {
setSelectedItem(item);
setShowAddModal(true);
};
const handleOut = (item: StockItem) => {
setSelectedItem(item);
setShowOutModal(true);
};
const handleHistory = (item: StockItem) => {
setSelectedItem(item);
setShowHistoryModal(true);
};
const handleDelete = async (id: string) => {
if (confirm('Tem certeza que deseja excluir este lote? Todo o histórico será perdido.')) {
try {
await stockService.delete(id);
fetchItems();
} catch (error) {
console.error('Error deleting item:', error);
alert('Erro ao excluir item.');
}
}
};
const handlePrintInventory = async () => {
try {
setIsPrintingInventory(true);
// Buscar movimentações para todos os itens
const movementsMap = new Map<string, StockMovement[]>();
await Promise.all(
items.map(async (item) => {
try {
const movements = await stockService.getMovements(item._id!);
movementsMap.set(item._id!, movements);
} catch (error) {
console.error(`Error fetching movements for ${item._id}:`, error);
movementsMap.set(item._id!, []);
}
})
);
setAllMovements(movementsMap);
// Aguardar renderização e imprimir
setTimeout(() => {
window.print();
setIsPrintingInventory(false);
}, 500);
} catch (error) {
console.error('Error generating inventory report:', error);
alert('Erro ao gerar relatório de inventário.');
setIsPrintingInventory(false);
}
};
const filteredItems = items.filter(item => {
const searchLower = searchTerm.toLowerCase();
// Handle type checking carefully. If type is missing, assume PAINT.
const type = (typeof item.dataSheetId === 'object' ? item.dataSheetId.type : '') || 'PAINT';
const isThinner = type === 'THINNER' || type === 'DILUENTE';
// Tab Filter
if (activeTab === 'THINNER' && !isThinner) return false;
if (activeTab === 'PAINT' && isThinner) return false;
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : '';
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
return (
item.rrNumber.toLowerCase().includes(searchLower) ||
item.batchNumber.toLowerCase().includes(searchLower) ||
productName.toLowerCase().includes(searchLower) ||
manufacturer.toLowerCase().includes(searchLower)
);
});
const groupedItems = React.useMemo(() => {
const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>();
filteredItems.forEach(item => {
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Unknown';
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
const key = `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
if (!groups.has(key)) {
groups.set(key, {
items: [],
totalQty: 0,
minStock: item.minStock || 0,
unit: item.unit,
productName,
color: item.color || '-',
manufacturer
});
}
const group = groups.get(key)!;
group.items.push(item);
group.totalQty += item.quantity;
// Ensure we take the max minStock found if they differ (though backend enforces consistency)
if (item.minStock && item.minStock > group.minStock) {
group.minStock = item.minStock;
}
});
return Array.from(groups.values());
}, [filteredItems]);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const toggleGroup = (key: string) => {
const newExpanded = new Set(expandedGroups);
if (newExpanded.has(key)) {
newExpanded.delete(key);
} else {
newExpanded.add(key);
}
setExpandedGroups(newExpanded);
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between gap-4">
<div>
<h1 className="text-3xl font-black text-text-main flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-primary/20 flex items-center justify-center">
<Package size={24} className="text-primary" />
</div>
Gestão de Estoque
</h1>
<p className="text-text-muted mt-2">Controle de Tintas e Diluentes</p>
</div>
<div className="flex gap-3">
<button
onClick={handlePrintInventory}
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border/40 text-text-main rounded-xl hover:bg-surface-hover transition-colors font-semibold"
disabled={isPrintingInventory || items.length === 0}
>
<Printer size={20} />
Inventário
</button>
{isAdmin() && activeTab === 'THINNER' && (
<button
onClick={() => setShowDiluentModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border/40 text-text-main rounded-xl hover:bg-surface-hover transition-colors font-semibold"
>
<Plus size={20} />
Cadastrar Diluente
</button>
)}
{isAdmin() && (
<button
onClick={() => { setSelectedItem(null); setShowAddModal(true); }}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-semibold"
>
<Plus size={20} />
Nova Entrada
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-border/40 mb-6">
<button
onClick={() => setActiveTab('PAINT')}
className={`px-6 py-3 font-medium text-sm transition-colors border-b-2 ${activeTab === 'PAINT'
? 'border-primary text-primary'
: 'border-transparent text-text-muted hover:text-text-main'
}`}
>
Tintas
</button>
<button
onClick={() => setActiveTab('THINNER')}
className={`px-6 py-3 font-medium text-sm transition-colors border-b-2 ${activeTab === 'THINNER'
? 'border-primary text-primary'
: 'border-transparent text-text-muted hover:text-text-main'
}`}
>
Diluentes
</button>
</div>
{/* Filters */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="Buscar por RR, Lote ou Produto..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-12 pr-4 py-3 bg-surface border border-border/40 rounded-xl text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/>
</div>
</div>
{/* Table */}
<div className="bg-surface rounded-2xl border border-border/40 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-text-muted">Carregando...</div>
) : filteredItems.length === 0 ? (
<div className="p-8 text-center text-text-muted">Nenhum item encontrado.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-soft border-b border-border/40">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase w-10"></th>
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Produto / Grupo</th>
{activeTab === 'PAINT' && (
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Cor</th>
)}
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Quantidade Total</th>
<th className="px-6 py-4 text-left text-xs font-bold text-text-muted uppercase">Lotes</th>
<th className="px-6 py-4 text-right text-xs font-bold text-text-muted uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{groupedItems.map((group, index) => {
const groupKey = `${group.productName}-${group.color}-${index}`;
const isExpanded = expandedGroups.has(groupKey);
const isLowStock = group.minStock > 0 && group.totalQty < group.minStock;
return (
<React.Fragment key={groupKey}>
{/* Group Row */}
<tr
className={`hover:bg-surface-hover transition-colors cursor-pointer ${isExpanded ? 'bg-surface-hover/50' : ''}`}
onClick={() => toggleGroup(groupKey)}
>
<td className="px-6 py-4 text-text-muted">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="font-bold text-text-main text-base">{group.productName}</span>
<span className="text-xs text-text-muted">{group.manufacturer}</span>
</div>
</td>
{activeTab === 'PAINT' && (
<td className="px-6 py-4 text-text-secondary">{group.color}</td>
)}
<td className="px-6 py-4 font-bold text-lg">
<span className={isLowStock ? 'text-red-500 animate-blink flex items-center gap-2' : 'text-green-500'}>
{isLowStock && <AlertCircle size={16} />}
{group.totalQty.toFixed(1)} {group.unit}
</span>
{group.minStock > 0 && (
<span className="block text-[10px] text-text-muted font-normal">
Mín: {group.minStock} {group.unit}
</span>
)}
</td>
<td className="px-6 py-4 text-text-secondary">
{group.items.length} lote(s)
</td>
<td className="px-6 py-4 text-right">
{/* Actions if needed for group? */}
</td>
</tr>
{/* Expanded Item Rows */}
{isExpanded && group.items.map(item => {
const isExpired = item.expirationDate && new Date(item.expirationDate) < new Date();
// Check individual item min stock for legacy reasons? No, rely on group.
return (
<tr key={item._id} className="bg-surface-soft/50 hover:bg-surface-hover/80 transition-colors border-l-4 border-l-primary/20">
<td className="px-6 py-3"></td> {/* Indentation */}
<td className="px-6 py-3 font-mono text-xs text-text-muted">
<div className="flex flex-col gap-1">
<div>RR: <span className="text-text-main font-bold">{item.rrNumber}</span></div>
{activeTab === 'THINNER' && (
<div className="text-text-secondary">Lote: {item.batchNumber}</div>
)}
</div>
</td>
{activeTab === 'PAINT' && (
<td className="px-6 py-3 text-xs text-text-secondary">
Lote: {item.batchNumber}
</td>
)}
<td className="px-6 py-3 font-bold text-sm">
{item.quantity} {item.unit}
</td>
<td className="px-6 py-3">
{activeTab === 'PAINT' ? (
item.expirationDate ? (
<span className={`text-xs ${isExpired ? 'text-red-500 font-bold' : 'text-text-secondary'}`}>
Val: {new Date(item.expirationDate).toLocaleDateString()}
</span>
) : '-'
) : (
<span className="text-xs text-text-muted">-</span>
)}
</td>
<td className="px-6 py-3 text-right flex justify-end gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleHistory(item); }}
className="p-1.5 text-text-secondary hover:bg-surface-hover rounded-lg"
title="Histórico"
>
<History size={16} />
</button>
{isAdmin() && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleOut(item); }}
className="p-1.5 text-amber-500 hover:bg-amber-500/10 rounded-lg"
title="Realizar Baixa"
>
<ArrowDown size={16} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleEdit(item); }}
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg"
title="Editar"
>
<Edit size={16} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(item._id!); }}
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg"
title="Excluir"
>
<Trash2 size={16} />
</button>
</>
)}
</td>
</tr>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
{showAddModal && (
<StockModal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setSelectedItem(null); }}
onSuccess={() => { fetchItems(); setShowAddModal(false); setSelectedItem(null); }}
initialData={selectedItem || undefined}
initialType={activeTab}
/>
)}
{showOutModal && selectedItem && (
<StockOutModal
isOpen={showOutModal}
onClose={() => { setShowOutModal(false); setSelectedItem(null); }}
onSuccess={() => { fetchItems(); setShowOutModal(false); setSelectedItem(null); }}
item={selectedItem}
/>
)}
{showHistoryModal && selectedItem && (
<StockHistoryModal
isOpen={showHistoryModal}
onClose={() => { setShowHistoryModal(false); setSelectedItem(null); }}
item={selectedItem}
onUpdate={fetchItems}
/>
)}
{isPrintingInventory && (
<StockInventoryReport
items={filteredItems}
movements={allMovements}
logoUrl={logoUrl}
reportType={activeTab}
/>
)}
{showDiluentModal && (
<DiluentListModal
isOpen={showDiluentModal}
onClose={() => setShowDiluentModal(false)}
/>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
import api from './api';
export interface AnalysisResult {
pieceDescription: string;
schemeName: string;
schemeType?: string;
realYield: number;
theoreticalYield: number;
yieldVariance: number;
yieldStatus: 'approved' | 'warning' | 'critical';
diluentUsed: number;
volumeUsed: number;
realDilution: number;
targetDilution: number;
dilutionStatus: 'approved' | 'warning' | 'critical';
realDFT: number;
minDFT: number;
maxDFT: number;
dftStatus: 'approved' | 'warning' | 'critical';
notes: string[];
}
export const getProjectAnalysis = async (projectId: string) => {
return api.get<AnalysisResult[]>(`/projects/${projectId}/analysis`);
};

View File

@@ -0,0 +1,79 @@
// API service configuration v1.4 - with auth and error interceptors
import axios from 'axios';
import { triggerGuestWarning } from '../utils/toastHandler';
export const getBaseUrl = () => {
// Priority: Env var -> Relative path (handled by Vite proxy in dev, or Nginx/Vercel in prod)
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
return '/api';
};
const api = axios.create({
baseURL: getBaseUrl(),
headers: {
'Content-Type': 'application/json',
},
});
// Store the current user's clerk ID and Organization ID/Name
let currentClerkUserId: string | null = null;
let currentOrgId: string | null = null;
let currentOrgName: string | null = null;
// Function to set the clerk user ID (called from AuthContext)
export const setApiClerkUserId = (clerkId: string | null) => {
currentClerkUserId = clerkId;
};
// Function to set the organization ID and Name (called from Layout/Context)
export const setApiOrgData = (orgId: string | null, orgName: string | null = null) => {
currentOrgId = orgId;
currentOrgName = orgName;
};
// Legacy support
export const setApiOrgId = (orgId: string | null) => {
setApiOrgData(orgId, null);
};
// Alias for consistency
export const setApiOrganizationId = setApiOrgId;
// Request interceptor to add clerk user ID and Org ID headers
api.interceptors.request.use(
(config) => {
if (currentClerkUserId) {
config.headers['x-clerk-user-id'] = currentClerkUserId;
}
if (currentOrgId) {
config.headers['x-organization-id'] = currentOrgId;
}
if (currentOrgName) {
// Encode to handle special characters
config.headers['x-organization-name'] = encodeURIComponent(currentOrgName);
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle 403 errors (guest access denied)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
// Check if it's a guest permission error
const errorMessage = error.response?.data?.error || '';
if (errorMessage.includes('Convidados') || errorMessage.includes('guest') || errorMessage.includes('permissão')) {
triggerGuestWarning();
}
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,37 @@
import api, { getBaseUrl } from './api';
export { getBaseUrl };
import type { TechnicalDataSheet } from '../types';
export const getDataSheets = async () => {
return api.get<TechnicalDataSheet[]>('/datasheets');
};
export const createDataSheet = async (formData: FormData) => {
return api.post<TechnicalDataSheet>('/datasheets', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
export const updateDataSheet = async (id: string, formData: FormData) => {
return api.put<TechnicalDataSheet>(`/datasheets/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
export const deleteDataSheet = async (id: string) => {
return api.delete(`/datasheets/${id}`);
};
export const extractDataSheet = async (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post('/datasheets/extract', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};

View File

@@ -0,0 +1,22 @@
import api from './api';
import type { GeometryType } from '../types';
export const getAllTypes = async () => {
return api.get<GeometryType[]>('/geometry-types');
};
export const createType = async (data: { name: string; efficiencyLoss: number }) => {
return api.post<GeometryType>('/geometry-types', data);
};
export const updateType = async (id: string, data: { name: string; efficiencyLoss: number }) => {
return api.put<GeometryType>(`/geometry-types/${id}`, data);
};
export const deleteType = async (id: string) => {
return api.delete(`/geometry-types/${id}`);
};
export const restoreDefaults = async () => {
return api.post<GeometryType[]>('/geometry-types/restore', {});
};

View File

@@ -0,0 +1,88 @@
import api from './api';
export interface StockItem {
id?: string;
_id?: string;
organizationId?: string;
dataSheetId: string | any; // Can be object when populated
rrNumber: string;
batchNumber: string;
color?: string;
invoiceNumber?: string;
receivedBy?: string;
quantity: number;
unit: string;
minStock?: number;
expirationDate?: string;
entryDate?: string;
notes?: string;
}
export interface StockMovement {
id?: string;
_id?: string;
stockItemId: string;
type: 'ENTRY' | 'ADJUSTMENT' | 'CONSUMPTION';
quantity: number;
date: string;
responsible: string;
reason?: string;
requester?: string;
notes?: string;
movementNumber?: number;
}
export const stockService = {
getAll: async (dataSheetId?: string) => {
const response = await api.get('/stock', { params: { dataSheetId } });
return response.data;
},
getById: async (id: string) => {
const response = await api.get(`/stock/${id}`);
return response.data;
},
getMovements: async (id: string) => {
const response = await api.get(`/stock/${id}/movements`);
return response.data;
},
getAuditLogs: async (id: string) => {
const response = await api.get(`/stock/${id}/logs`);
return response.data;
},
create: async (data: Partial<StockItem>) => {
const response = await api.post('/stock', data);
return response.data;
},
update: async (id: string, data: Partial<StockItem>) => {
const response = await api.put(`/stock/${id}`, data);
return response.data;
},
adjust: async (id: string, data: { quantityDelta: number; reason: string }) => {
const response = await api.post(`/stock/${id}/adjust`, data);
return response.data;
},
consume: async (id: string, data: { quantityConsumed: number; requester: string; date?: string }) => {
const response = await api.post(`/stock/${id}/consume`, data);
return response.data;
},
delete: async (id: string) => {
await api.delete(`/stock/${id}`);
},
updateMovement: async (id: string, data: Partial<StockMovement>) => {
const response = await api.put(`/stock/movements/${id}`, data);
return response.data;
},
deleteMovement: async (id: string) => {
await api.delete(`/stock/movements/${id}`);
}
};

View File

@@ -0,0 +1,75 @@
import api from './api';
export interface SystemSettings {
settingsId: string;
appName: string;
appSubtitle: string;
appLogoUrl?: string;
updatedBy?: string;
}
export const systemSettingsService = {
getSettings: async (): Promise<SystemSettings> => {
const response = await api.get('/system-settings');
return response.data;
},
updateSettings: async (settings: Partial<SystemSettings>): Promise<SystemSettings> => {
// Axios interceptors in api.ts automatically handle x-clerk-user-id and x-organization-id headers
const response = await api.put('/system-settings', settings);
return response.data;
},
uploadLogo: async (file: File): Promise<string> => {
const formData = new FormData();
formData.append('logo', file);
const response = await api.post('/system-settings/logo', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data.url;
},
getGlobalUsers: async (): Promise<GlobalUser[]> => {
const response = await api.get('/system-settings/users');
return response.data;
},
getGlobalOrganizations: async (): Promise<GlobalOrganization[]> => {
const response = await api.get('/system-settings/organizations');
return response.data;
},
toggleOrganizationBan: async (organizationId: string, isBanned: boolean): Promise<any> => {
const response = await api.post('/system-settings/organizations/ban', { organizationId, isBanned });
return response.data;
}
};
export interface GlobalUser {
_id: string;
clerkId: string;
name: string;
email: string;
role: string;
isBanned: boolean;
createdAt: string;
}
export interface GlobalOrganization {
_id: string; // organizationId
memberCount: number;
lastActive: string;
isBanned: boolean;
name?: string; // Added
members: {
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
clerkUserId: string;
isBanned: boolean;
}[];
}

View File

@@ -0,0 +1,9 @@
import api from './api';
import type { YieldStudy } from '../types';
const BASE_URL = '/yield-studies';
export const getStudies = () => api.get<YieldStudy[]>(BASE_URL);
export const createStudy = (data: Partial<YieldStudy>) => api.post<YieldStudy>(BASE_URL, data);
export const updateStudy = (id: string, data: Partial<YieldStudy>) => api.put<YieldStudy>(`${BASE_URL}/${id}`, data);
export const deleteStudy = (id: string) => api.delete(`${BASE_URL}/${id}`);

View File

@@ -0,0 +1,402 @@
/* Report Print Styles */
@page {
size: A4;
margin: 10mm;
}
.report-container {
visibility: visible;
position: absolute;
left: 0;
top: 0;
width: 210mm;
min-height: 297mm;
background: #fff;
padding: 12mm;
box-sizing: border-box;
color: #000;
font-family: system-ui, -apple-system, sans-serif;
font-size: 11pt;
z-index: 9999;
}
@media screen {
.report-container {
display: none;
}
}
@media print {
html,
body {
background-color: #fff !important;
color: #000 !important;
margin: 0 !important;
padding: 0 !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Esconde tudo */
body * {
visibility: hidden !important;
}
/* Mostra apenas o container do relatório e seus filhos */
.report-container,
.report-container * {
visibility: visible !important;
}
.report-container {
display: block !important;
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 210mm !important;
/* Largura fixa A4 */
height: auto !important;
min-height: 0 !important;
margin: 0 !important;
padding: 8mm !important;
background-color: #fff !important;
border: none !important;
}
}
.report-header {
display: grid;
grid-template-columns: 80px 1fr 180px;
align-items: center;
border-bottom: 1px solid #999;
padding-bottom: 4mm;
margin-bottom: 6mm;
position: relative;
}
.system-title {
font-size: 8pt;
font-weight: 700;
color: #000;
text-transform: none;
margin: 0;
}
.brand-title {
font-size: 13pt;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.brand-subtitle {
font-size: 8pt;
color: #666;
margin-top: 1mm;
font-weight: 500;
}
.brand-logo {
width: 36mm;
height: 36mm;
object-fit: contain;
border-radius: 2mm;
}
.logo-placeholder {
width: 36mm;
height: 36mm;
border-radius: 2mm;
border: 1px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
background: #f9f9f9;
}
.meta {
text-align: right;
font-size: 10pt;
color: #000;
}
.meta div {
margin-bottom: 1mm;
}
.tag {
display: inline-block;
border: 1px solid #333;
border-radius: 999px;
padding: 1mm 3mm;
font-size: 7pt;
text-transform: uppercase;
letter-spacing: 0.14em;
margin-top: 2mm;
}
.summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4mm;
margin-bottom: 6mm;
}
.summary-item {
border: 1px solid #ccc;
border-radius: 2mm;
padding: 3mm;
}
.summary-label {
font-size: 7pt;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #555;
margin-bottom: 1mm;
}
.summary-value {
font-size: 13pt;
font-weight: 700;
margin-bottom: 1mm;
}
.summary-sub {
font-size: 8pt;
color: #555;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: baseline;
margin: 2mm 0 2mm;
border-top: 1px solid #ccc;
padding-top: 2mm;
width: 100%;
}
.section-title h2 {
font-size: 9pt;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #333;
margin: 0;
}
.section-title span {
font-size: 8pt;
color: #666;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 1mm;
font-size: 9pt;
}
.table thead th {
text-align: left;
padding: 2mm 1.5mm;
border-bottom: 1px solid #000;
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.table tbody tr {
border-bottom: 0.4pt solid #bbb;
}
.table td {
padding: 2mm 1.5mm;
vertical-align: top;
}
.col-cod {
width: 16mm;
}
.col-obra {
width: 45mm;
}
.col-evol {
width: 14mm;
}
.col-cron {
width: 36mm;
}
.col-peso {
width: 22mm;
}
.col-tinta {
width: 40mm;
}
.col-cor {
width: 20mm;
}
.obra-nome {
font-weight: 600;
font-size: 9pt;
}
.obra-cliente {
font-size: 8pt;
color: #555;
}
.evol-bar {
margin-top: 1mm;
height: 3mm;
border-radius: 999px;
background: #eee;
overflow: hidden;
width: 100%;
}
.evol-fill {
height: 100%;
background: #000 !important;
width: var(--progress, 0%);
}
.cron {
font-size: 8pt;
line-height: 1.4;
}
.tinta {
font-size: 8pt;
line-height: 1.3;
}
.tinta strong {
display: block;
font-weight: 600;
}
.report-footer {
margin-top: 4mm;
border-top: 1px solid #999;
padding-top: 2mm;
display: flex;
justify-content: space-between;
align-items: center;
/* Alinha o título do sistema com o texto do rodapé */
font-size: 7.5pt;
color: #555;
}
.sig-group {
text-align: right;
}
.sig-line {
margin-top: 2mm;
}
.sig-line span {
display: inline-block;
min-width: 38mm;
border-bottom: 0.4pt solid #000;
margin-left: 2mm;
height: 3mm;
}
.badge {
font-size: 7pt;
font-weight: 700;
padding: 0.5mm 1.5mm;
border-radius: 1mm;
text-transform: uppercase;
}
.badge-ok {
background: #eee;
border: 1px solid #000;
}
.badge-err {
background: #000;
color: #fff;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.font-bold {
font-weight: 700;
}
.font-medium {
font-weight: 500;
}
.text-11pt {
font-size: 11pt;
}
.text-8pt {
font-size: 8pt;
}
.text-7pt {
font-size: 7pt;
}
.uppercase {
text-transform: uppercase;
}
.p-10mm {
padding: 10mm;
}
.p-5mm {
padding: 5mm;
}
.text-gray-muted {
color: #999;
}
.grid-2col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8mm;
}
.w-15 {
width: 15%;
}
.w-20 {
width: 20%;
}
.w-25 {
width: 25%;
}
.w-35 {
width: 35%;
}
.w-40 {
width: 40%;
}

226
src/client/types.ts Normal file
View File

@@ -0,0 +1,226 @@
export interface Project {
id: string;
name: string;
client: string;
startDate: string | null;
endDate: string | null;
environment: string | null;
technician: string | null;
weightKg?: number | null;
paintedWeight?: number;
createdAt?: string;
updatedAt?: string;
parts?: Part[];
paintingSchemes?: PaintingScheme[];
applicationRecords?: ApplicationRecord[];
inspections?: Inspection[];
createdBy?: string;
}
export interface Part {
id: string;
projectId: string;
description: string;
dimensions?: string;
weight?: number;
type?: string;
area?: number;
complexity?: number;
quantity: number;
notes?: string;
}
export interface PaintingScheme {
id: string;
projectId: string;
name: string;
type?: string;
coat?: string; // Primer, Intermediate, Finish, Stripe Coat
solidsVolume?: number;
yieldTheoretical?: number;
epsMin?: number;
epsMax?: number;
dilution?: number;
manufacturer?: string;
color?: string;
notes?: string;
// Consumption Planning
paintConsumption?: number;
thinnerConsumption?: number;
paintId?: string | TechnicalDataSheet; // ID or Populated
thinnerId?: string | TechnicalDataSheet; // ID or Populated
colorHex?: string;
thinnerSymbol?: string;
createdBy?: string;
}
export interface ApplicationRecord {
id: string;
projectId: string;
coatStage: string;
pieceDescription?: string;
date?: string;
operator?: string;
realWeight?: number;
volumeUsed?: number;
areaPainted?: number;
wetThicknessAvg?: number;
dryThicknessCalc?: number;
realYield?: number;
method?: string;
diluentUsed?: number;
notes?: string;
items?: {
partId: string;
quantity: number;
}[];
createdBy?: string;
}
export interface Inspection {
id: string;
projectId: string;
applicationRecordId?: string; // Link to Lote
stockItemId?: string | {
_id: string;
batchNumber: string;
dataSheetId: {
name: string;
type?: string;
manufacturer?: string;
};
}; // Link to Stock Item (Paint)
instrumentId?: string | {
_id: string;
name: string;
serialNumber: string;
type: string;
}; // Link to Instrument
type?: 'painting' | 'surface_treatment';
// Common
date?: string;
inspector?: string;
partTemperature?: number;
weightKg?: number;
appearance?: string; // 'approved' | 'rejected' | 'notes'
defects?: string;
photos?: string[];
// Painting
pieceDescription?: string;
epsPoints?: (number | null)[];
adhesionTest?: string;
// Surface Treatment
batch?: string;
treatmentExecutor?: string;
treatmentType?: string;
cleaningDegree?: string;
roughnessReadings?: (number | null)[];
flashRust?: string;
temperature?: number;
relativeHumidity?: number;
period?: 'morning' | 'afternoon' | 'night';
createdBy?: string;
}
export interface TechnicalDataSheet {
id: string;
_id?: string;
name: string;
manufacturer?: string;
type?: string;
fileUrl: string;
uploadDate: string;
solidsVolume?: number;
density?: number;
mixingRatio?: string;
yieldTheoretical?: number;
wftMin?: number;
wftMax?: number;
dftMin?: number;
dftMax?: number;
reducer?: string;
mixingRatioWeight?: string;
mixingRatioVolume?: string;
dftReference?: number;
yieldFactor?: number;
dilution?: number;
notes?: string;
manufacturerCode?: string;
minStock?: number;
typicalApplication?: string;
}
export interface PieceCategory {
id: string;
name: string;
weight: number;
area?: number;
historicalYield: number;
historicalDft: number;
efficiency: number;
litrosPeso?: number; // Consumo calculado por peso
litrosArea?: number; // Consumo calculado por área
}
export interface YieldStudy {
id: string;
name: string;
dataSheetId: string;
targetDft: number;
dilutionPercent: number;
categories: PieceCategory[];
createdAt: string;
updatedAt: string;
totalWeight: number;
estimatedPaintVolume: number; // Por peso (kg)
estimatedReducerVolume: number; // Por peso (kg)
estimatedPaintVolumeByArea?: number; // Por área (m²)
estimatedReducerVolumeByArea?: number; // Por área (m²)
averageComplexity: number;
}
// User Roles System
export type UserRole = 'guest' | 'user' | 'admin';
export interface AppUser {
id: string;
_id?: string;
clerkId: string;
email: string;
name: string;
role: UserRole;
isBanned: boolean;
createdAt: string;
updatedAt: string;
organizationId?: string | null;
organizationRole?: UserRole | null;
}
export interface GeometryType {
id: string;
_id?: string;
name: string;
efficiencyLoss?: number;
updatedAt?: string;
}
export type NotificationType = 'info' | 'warning' | 'error' | 'success';
export interface INotification {
_id: string;
title: string;
message: string;
type: NotificationType;
isRead: boolean;
isArchived: boolean;
archivedBy: string[];
deletedBy: string[];
createdAt: string;
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,19 @@
export interface IInstrument {
_id: string;
organizationId: string;
name: string;
type: string;
manufacturer?: string;
modelName?: string;
serialNumber: string;
calibrationDate?: string; // ISO Date
calibrationExpirationDate?: string; // ISO Date
certificateUrl?: string;
status: 'active' | 'inactive' | 'maintenance' | 'expired';
notes?: string;
createdAt: string;
updatedAt: string;
}
export type CreateInstrumentDTO = Omit<IInstrument, '_id' | 'createdAt' | 'updatedAt' | 'organizationId'>;
export type UpdateInstrumentDTO = Partial<CreateInstrumentDTO>;

View File

@@ -0,0 +1,12 @@
// Global toast handler for use outside React components (like in axios interceptor)
let globalShowGuestWarning: (() => void) | null = null;
export const setGlobalToastHandler = (handler: () => void) => {
globalShowGuestWarning = handler;
};
export const triggerGuestWarning = () => {
if (globalShowGuestWarning) {
globalShowGuestWarning();
}
};

67
src/server/app.ts Normal file
View File

@@ -0,0 +1,67 @@
import express from 'express';
import cors from 'cors';
import projectRoutes from './routes/projectRoutes.js';
import partRoutes from './routes/partRoutes.js';
import paintingSchemeRoutes from './routes/paintingSchemeRoutes.js';
import applicationRecordRoutes from './routes/applicationRecordRoutes.js';
import inspectionRoutes from './routes/inspectionRoutes.js';
import analysisRoutes from './routes/analysisRoutes.js';
import dataSheetRoutes from './routes/dataSheetRoutes.js';
import yieldStudyRoutes from './routes/yieldStudyRoutes.js';
import userRoutes from './routes/userRoutes.js';
import systemSettingsRoutes from './routes/systemSettingsRoutes.js';
import geometryTypeRoutes from './routes/geometryTypeRoutes.js';
import stockRoutes from './routes/stockRoutes.js';
import notificationRoutes from './routes/notificationRoutes.js';
import instrumentRoutes from './routes/instrumentRoutes.js';
import messageRoutes from './routes/messageRoutes.js';
import backupRoutes from './routes/backupRoutes.js';
import path from 'path';
const app = express();
app.use(cors({
origin: '*', // Be more specific in production
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-clerk-user-id', 'x-organization-id']
}));
app.use(express.json());
import { extractUser } from './middleware/roleMiddleware.js';
app.use(extractUser);
// Static Uploads
import fs from 'fs';
const uploadsPath = path.join(process.cwd(), 'uploads');
// Ensure uploads directory exists
if (!fs.existsSync(uploadsPath)) {
fs.mkdirSync(uploadsPath, { recursive: true });
}
app.use('/uploads', express.static(uploadsPath));
// Routes
app.use('/api/users', userRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/parts', partRoutes);
app.use('/api/painting-schemes', paintingSchemeRoutes);
app.use('/api/application-records', applicationRecordRoutes);
app.use('/api/inspections', inspectionRoutes);
app.use('/api', analysisRoutes);
app.use('/api/datasheets', dataSheetRoutes);
app.use('/api/datasheets', dataSheetRoutes);
app.use('/api/yield-studies', yieldStudyRoutes);
app.use('/api/system-settings', systemSettingsRoutes);
app.use('/api/geometry-types', geometryTypeRoutes);
app.use('/api/stock', stockRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/instruments', instrumentRoutes);
app.use('/api/messages', messageRoutes);
app.use('/api/backup', backupRoutes);
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
export default app;

View File

@@ -0,0 +1,46 @@
import mongoose from 'mongoose';
import { GridFSBucket } from 'mongodb';
export let bucket: GridFSBucket;
export const connectDB = async () => {
try {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI is not defined in environment variables');
}
if (mongoose.connection.readyState >= 1) {
console.log('Using existing MongoDB connection');
if (!bucket && mongoose.connection.db) {
bucket = new GridFSBucket(mongoose.connection.db, { bucketName: 'pdfs' });
console.log('✅ GridFS Bucket re-initialized');
}
return;
}
console.log('Connecting to MongoDB...');
if (!uri) console.error('MONGODB_URI is undefined!');
await mongoose.connect(uri, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
});
console.log('✅ MongoDB connected successfully');
const db = mongoose.connection.db;
if (!db) {
throw new Error('Database connection not established');
}
bucket = new GridFSBucket(db, {
bucketName: 'pdfs'
});
console.log('✅ GridFS Bucket initialized');
} catch (error) {
console.error('❌ MongoDB connection error:', error);
console.warn('⚠️ Server will continue running for debugging, but database features will be unavailable.');
// process.exit(1);
}
};

View File

@@ -0,0 +1,153 @@
import { Request, Response } from 'express';
import * as applicationRecordService from '../services/applicationRecordService.js';
import * as paintingSchemeService from '../services/paintingSchemeService.js';
import * as inspectionService from '../services/inspectionService.js';
interface AnalysisResult {
pieceDescription: string;
schemeName: string;
schemeType: string;
// Yield (Rendimento)
realYield: number;
theoreticalYield: number;
yieldVariance: number; // percentage
yieldStatus: 'approved' | 'warning' | 'critical';
// Dilution
diluentUsed: number;
volumeUsed: number;
realDilution: number;
targetDilution: number;
dilutionStatus: 'approved' | 'warning' | 'critical';
// Thickness (Espessura)
realDFT: number; // Average of dryThicknessCalc or inspection points
minDFT: number;
maxDFT: number;
dftStatus: 'approved' | 'warning' | 'critical';
notes: string[];
}
export const getProjectAnalysis = async (req: Request, res: Response) => {
try {
const { projectId } = req.params;
const records = await applicationRecordService.getApplicationRecordsByProject(projectId as string);
const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string);
const inspections = await inspectionService.getInspectionsByProject(projectId as string);
const analysis: AnalysisResult[] = [];
// Group records by pieceDescription to handle multiple layers/records for same piece if needed
// For simple MVP, assuming 1 record per piece for calculation or analyzing individual records
for (const record of records) {
// Find scheme used (assuming application record doesn't store schemeId directly, we might need to match by name or context?
// Checking types.ts, ApplicationRecord doesn't have schemeId. It has 'coatStage'.
// PaintingScheme has 'type' which might match 'coatStage' or project context.
// Without direct link, we might have to infer or maybe the user links them by name/description effectively?
// Actually, in many cases there's only one scheme active per project or we check if schemes exist.
// Let's try to match if possible, or use a default if only one scheme exists.
// Note: In a real robust system we'd link Record -> Scheme.
// Here we will try to find a scheme that matches the coatStage or just take the first one if length is 1.
const scheme = schemes.find(s => s.type === record.coatStage) || schemes[0];
if (!scheme) continue;
// Calculations
// 1. Yield
// Yield = Area / Volume
const realYield = (record.areaPainted && record.volumeUsed)
? (record.areaPainted / record.volumeUsed)
: 0;
const theoreticalYield = scheme.yieldTheoretical || 0;
const yieldData = calculateVariance(realYield, theoreticalYield);
// 2. Dilution
const diluent = record.diluentUsed || 0;
const volume = record.volumeUsed || 1; // avoid div by zero
const realDilution = (diluent / volume) * 100;
const targetDilution = scheme.dilution || 0;
const dilutionData = calculateVariance(realDilution, targetDilution); // true for "lower is not always better/worse", requires exact match?
// Actually for dilution, usually there's a limit. Let's assume variance check.
// 3. DFT (Dry Film Thickness)
// Use inspection data if available for this piece, otherwise calculated
const pieceInspection = inspections.find(i => i.pieceDescription === record.pieceDescription);
let realDFT = 0;
if (pieceInspection) {
const points = (pieceInspection.epsPoints || []).filter((p: number | null | undefined): p is number => p !== undefined && p !== null);
if (points.length > 0) {
realDFT = points.reduce((a: number, b: number) => a + b, 0) / points.length;
}
} else {
realDFT = record.dryThicknessCalc || 0;
}
const minDFT = scheme.epsMin || 0;
const maxDFT = scheme.epsMax || 9999;
let dftStatus: 'approved' | 'warning' | 'critical' = 'approved';
if (realDFT < minDFT) dftStatus = 'critical';
else if (realDFT > maxDFT) dftStatus = 'warning';
analysis.push({
pieceDescription: record.pieceDescription || 'Desconhecido',
schemeName: scheme.name,
schemeType: scheme.type || '',
realYield: Number(realYield.toFixed(2)),
theoreticalYield: Number(theoreticalYield.toFixed(2)),
yieldVariance: Number(yieldData.variance.toFixed(2)),
yieldStatus: yieldData.status,
diluentUsed: diluent,
volumeUsed: volume,
realDilution: Number(realDilution.toFixed(1)),
targetDilution: Number(targetDilution.toFixed(1)),
dilutionStatus: dilutionData.status, // We might want to warn if dilution is > target
realDFT: Number(realDFT.toFixed(1)),
minDFT: minDFT,
maxDFT: maxDFT,
dftStatus,
notes: []
});
}
res.json(analysis);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
function calculateVariance(actual: number, target: number): { variance: number, status: 'approved' | 'warning' | 'critical' } {
if (!target) return { variance: 0, status: 'approved' };
// Variance % = ((Actual - Target) / Target) * 100
const variance = ((actual - target) / target) * 100;
// Rules (Simplified)
// Yield: Higher is usually good (efficient), but too high might mean too thin.
// Lower means using more paint -> expensive.
let status: 'approved' | 'warning' | 'critical' = 'approved';
// If deviation is > 20% (warning) or > 30% (critical)
if (Math.abs(variance) > 30) status = 'critical';
else if (Math.abs(variance) > 20) status = 'warning';
return { variance, status };
}

View File

@@ -0,0 +1,72 @@
import { Request, Response } from 'express';
import * as appRecordService from '../services/applicationRecordService.js';
import '../middleware/roleMiddleware.js'; // Ensure type augmentation
export const createApplicationRecord = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const createdBy = req.appUser?.clerkId;
const record = await appRecordService.createApplicationRecord({ ...req.body, organizationId, createdBy });
res.status(201).json(record);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getApplicationRecordsByProject = async (req: Request, res: Response) => {
try {
const { projectId } = req.params;
const organizationId = req.appUser?.organizationId;
const records = await appRecordService.getApplicationRecordsByProject(projectId as string, organizationId);
res.json(records);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const updateApplicationRecord = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.clerkId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const record = await appRecordService.updateApplicationRecord(
req.params.id as string,
req.body,
organizationId,
userId,
userRole as any,
isDeveloper
);
if (!record) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.json(record);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deleteApplicationRecord = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.clerkId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const success = await appRecordService.deleteApplicationRecord(
req.params.id as string,
organizationId,
userId,
userRole as any,
isDeveloper
);
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,285 @@
import { Request, Response } from 'express';
import * as dataSheetService from '../services/dataSheetService.js';
import fs from 'fs';
import * as pdfExtractionService from '../services/pdfExtractionService.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
export const getAllDataSheets = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
console.log('Backend: Fetching datasheets for org:', organizationId);
const sheets = await dataSheetService.getAllDataSheets(organizationId);
console.log(`Backend: Found ${sheets.length} sheets`);
res.json(sheets);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const extractData = async (req: AuthRequest, res: Response) => {
try {
const file = req.file;
if (!file) {
return res.status(400).json({ error: 'File is required' });
}
const fileBuffer = fs.readFileSync(file.path);
const data = await pdfExtractionService.extractDataFromPdf(fileBuffer);
// Return extracted data AND the file path so we don't need to re-upload
res.json({
...data,
tempFilePath: file.path
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const createDataSheet = async (req: AuthRequest, res: Response) => {
try {
const file = req.file;
const {
name, manufacturer, type, solidsVolume, density,
mixingRatio, mixingRatioWeight, mixingRatioVolume,
yieldTheoretical, dftReference, yieldFactor,
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
notes, fileUrl,
manufacturerCode, minStock, typicalApplication
} = req.body;
const organizationId = req.appUser?.organizationId;
// Note: New logic prefers 'file' upload which we store in DB.
// If fileUrl is provided (legacy or external link), we use that but don't store binary.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let fileId: any = undefined;
let finalFileUrl = fileUrl || '';
if (file) {
// Read file buffer
const buffer = fs.readFileSync(file.path);
// Save to StoredFile collection
const { default: StoredFile } = await import('../models/StoredFile.js');
const newFile = await StoredFile.create({
filename: file.originalname,
contentType: file.mimetype,
data: buffer,
size: file.size,
uploadDate: new Date()
});
fileId = newFile._id;
finalFileUrl = newFile._id.toString(); // Use ID as URL reference for consistency with frontend expectations if possible, or we might need to adjust frontend to use /api/datasheets/file/:id
// Clean up temp file
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
}
if (!fileId && !finalFileUrl) {
// Check if fileUrl allows empty. The schema says optional now, but logically a datasheet usually has a file.
// However, for simplified Diluent registration, we might not have one.
// If the user didn't send a file and didn't send a URL, and schema is optional, we can proceed.
// But let's check if we want to enforce it.
// If manufacturerCode (Diluent indicator?) is present, maybe skip check?
// Actually, I removed 'required' from schema, so I should probably relax this check too.
// return res.status(400).json({ error: 'File is required' });
}
const newSheet = await dataSheetService.createDataSheet({
name,
manufacturer,
manufacturerCode,
type,
minStock: minStock ? Number(minStock) : undefined,
typicalApplication,
fileUrl: finalFileUrl,
fileId: fileId,
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
density: density ? Number(density) : undefined,
mixingRatio,
mixingRatioWeight,
mixingRatioVolume,
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
dftReference: dftReference ? Number(dftReference) : undefined,
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
wftMin: wftMin ? Number(wftMin) : undefined,
wftMax: wftMax ? Number(wftMax) : undefined,
dftMin: dftMin ? Number(dftMin) : undefined,
dftMax: dftMax ? Number(dftMax) : undefined,
reducer,
dilution: dilution ? Number(dilution) : undefined,
notes,
organizationId
});
// Notificação de Nova Ficha Técnica
if (organizationId) {
await notificationService.create({
organizationId,
title: 'Nova Ficha Técnica',
message: `A ficha técnica "${name}" (${manufacturer}) foi adicionada à biblioteca.`,
type: 'info',
metadata: { dataSheetId: newSheet._id, triggerType: 'datasheet_created' }
});
}
res.status(201).json(newSheet);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error creating datasheet:', error);
res.status(500).json({ error: message });
}
};
export const deleteDataSheet = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
// Find sheet to delete file if exists
// (Optional: Implement file deletion logic here if strict cleanup needed)
const success = await dataSheetService.deleteDataSheet(id as string, organizationId);
if (success) {
res.status(204).send();
} else {
res.status(404).json({ error: 'Data sheet not found' });
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const updateDataSheet = async (req: AuthRequest, res: Response) => {
try {
const id = req.params.id as string;
const file = req.file;
const organizationId = req.appUser?.organizationId;
const {
name, manufacturer, type, solidsVolume, density,
mixingRatio, mixingRatioWeight, mixingRatioVolume,
yieldTheoretical, dftReference, yieldFactor,
wftMin, wftMax, dftMin, dftMax, reducer, dilution,
notes, fileUrl
} = req.body;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updates: Record<string, any> = {
name,
manufacturer,
type,
notes,
solidsVolume: solidsVolume ? Number(solidsVolume) : undefined,
density: density ? Number(density) : undefined,
yieldTheoretical: yieldTheoretical ? Number(yieldTheoretical) : undefined,
dftReference: dftReference ? Number(dftReference) : undefined,
yieldFactor: yieldFactor ? Number(yieldFactor) : undefined,
wftMin: wftMin ? Number(wftMin) : undefined,
wftMax: wftMax ? Number(wftMax) : undefined,
dftMin: dftMin ? Number(dftMin) : undefined,
dftMax: dftMax ? Number(dftMax) : undefined,
reducer,
dilution: dilution ? Number(dilution) : undefined,
mixingRatio,
mixingRatioWeight,
mixingRatioVolume
};
if (file) {
// Read file buffer
const buffer = fs.readFileSync(file.path);
// Save to StoredFile collection
const { default: StoredFile } = await import('../models/StoredFile.js');
const newFile = await StoredFile.create({
filename: file.originalname,
contentType: file.mimetype,
data: buffer,
size: file.size,
uploadDate: new Date()
});
updates.fileId = newFile._id;
updates.fileUrl = newFile._id.toString();
// Clean up temp file
try {
fs.unlinkSync(file.path);
} catch (error) {
console.warn('Failed to delete temp file:', file.path, error);
}
} else if (fileUrl) {
updates.fileUrl = String(fileUrl);
// If fileUrl is being updated but not file, we might lose fileId reference?
// If the user sends the same fileUrl (which is the ID), it's fine.
// But if they send a new external URL, we should probably unset fileId.
// For now, let's assume if it's an external URL, fileId should remain unless explicitly cleared?
// Safer: if fileUrl is explicitly sent and doesn't match an ID format, maybe clear fileId?
// Actually, keep it simple.
}
const updatedSheet = await dataSheetService.updateDataSheet(id, updates, organizationId);
if (updatedSheet) {
res.json(updatedSheet);
} else {
res.status(404).json({ error: 'Data sheet not found' });
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error updating datasheet:', error);
res.status(500).json({ error: message });
}
};
export const getFile = async (req: Request, res: Response) => {
try {
const id_or_filename = req.params.id as string;
// Check if it's a MongoDB ObjectId (24 hex chars)
if (/^[0-9a-fA-F]{24}$/.test(id_or_filename)) {
const { default: StoredFile } = await import('../models/StoredFile.js');
const fileDoc = await StoredFile.findById(id_or_filename);
if (fileDoc) {
res.set('Content-Type', fileDoc.contentType || 'application/pdf');
res.set('Content-Disposition', `inline; filename="${fileDoc.filename}"`);
return res.send(fileDoc.data);
}
}
// Fallback to file system (legacy)
const stream = dataSheetService.getFileStream(id_or_filename);
stream.on('file', (file) => {
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${file.filename}"`);
});
stream.on('error', () => {
res.status(404).json({ error: 'File not found' });
});
stream.pipe(res);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error getting file:', error);
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,160 @@
import { Request, Response } from 'express';
import GeometryType from '../models/GeometryType.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
// Default geometry types to seed if none exist
const DEFAULT_TYPES = [
{ name: 'Guarda-corpo/escada', efficiencyLoss: 20 },
{ name: 'Vigas leves', efficiencyLoss: 20 },
{ name: 'Vigas médias', efficiencyLoss: 20 },
{ name: 'Vigas pesadas', efficiencyLoss: 20 },
{ name: 'Chaparia comum', efficiencyLoss: 20 },
{ name: 'Chapas de pisos (>0,5m²)', efficiencyLoss: 20 },
{ name: 'Calhas', efficiencyLoss: 20 },
{ name: 'Cantoneiras', efficiencyLoss: 20 },
{ name: 'Telhas', efficiencyLoss: 20 },
{ name: 'Tubulações (ret/red) <100mm', efficiencyLoss: 20 },
{ name: 'Tubulações (ret/red) >100mm', efficiencyLoss: 20 },
{ name: 'Peças diversas (outras)', efficiencyLoss: 20 }
];
export const getAllnames = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
console.log(`[GeometryType] Fetching for org: ${organizationId}, globalAdmin: ${isGlobalAdmin}`);
if (!organizationId && !isGlobalAdmin) {
return res.status(400).json({ error: 'Organization ID missing' });
}
// Search for org-specific types OR orphan types (legacy)
const query = isGlobalAdmin
? {}
: { $or: [{ organizationId }, { organizationId: { $exists: false } }, { organizationId: null }] };
let types = await GeometryType.find(query).sort({ name: 1 });
// Auto-seed if empty AND we HAVE an organization (don't seed for global view)
if (types.length === 0 && organizationId) {
console.log(`[GeometryType] No types found. Seeding defaults...`);
try {
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
types = await GeometryType.insertMany(seedData) as any;
console.log(`[GeometryType] Seeded ${types.length} types successfully.`);
} catch (seedError) {
console.error('[GeometryType] Seeding failed:', seedError);
return res.json([]);
}
}
res.json(types);
} catch (error: unknown) {
console.error('[GeometryType] Error in getAllnames:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const restoreDefaults = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
if (!organizationId) {
return res.status(400).json({ error: 'Organization ID missing' });
}
// Delete all existing types for this org
await GeometryType.deleteMany({ organizationId });
// Insert defaults
const seedData = DEFAULT_TYPES.map(t => ({ ...t, organizationId }));
const types = await GeometryType.insertMany(seedData);
res.json(types);
} catch (error: unknown) {
console.error('[GeometryType] Error in restoreDefaults:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const createType = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const { name, efficiencyLoss } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const newType = new GeometryType({
name,
efficiencyLoss: Number(efficiencyLoss) || 0,
organizationId
});
const saved = await newType.save();
res.status(201).json(saved);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('E11000')) {
return res.status(409).json({ error: 'A geometry type with this name already exists' });
}
res.status(500).json({ error: message });
}
};
export const updateType = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const { name, efficiencyLoss } = req.body;
const query = isGlobalAdmin
? { _id: id }
: { _id: id, organizationId };
const updated = await GeometryType.findOneAndUpdate(
query,
{ name, efficiencyLoss: Number(efficiencyLoss) },
{ new: true }
);
if (!updated) {
return res.status(404).json({ error: 'Record not found' });
}
res.json(updated);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deleteType = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const query = isGlobalAdmin
? { _id: id }
: { _id: id, organizationId };
const deleted = await GeometryType.findOneAndDelete(query);
if (!deleted) {
return res.status(404).json({ error: 'Record not found' });
}
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,117 @@
import { Request, Response } from 'express';
import * as inspectionService from '../services/inspectionService.js';
import { notificationService } from '../services/notificationService.js';
import '../middleware/roleMiddleware.js'; // Ensure type augmentation
export const createInspection = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const createdBy = req.appUser?.clerkId;
const inspection = await inspectionService.createInspection({
...req.body,
organizationId,
createdBy
});
// Notificação de Inspeção Reprovada
if (req.body.appearance === 'rejected' && organizationId) {
await notificationService.create({
organizationId,
title: 'Inspeção Reprovada',
message: `Uma inspeção foi reprovada na obra (ID: ${req.body.projectId}).`,
type: 'error',
metadata: { inspectionId: inspection._id, projectId: req.body.projectId, triggerType: 'inspection_rejected' }
});
}
res.status(201).json(inspection);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getInspectionsByProject = async (req: Request, res: Response) => {
try {
const { projectId } = req.params;
const organizationId = req.appUser?.organizationId;
const inspections = await inspectionService.getInspectionsByProject(projectId as string, organizationId);
res.json(inspections);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const updateInspection = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.clerkId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const inspection = await inspectionService.updateInspection(
req.params.id as string,
req.body,
organizationId,
userId,
userRole as any,
isDeveloper
);
if (!inspection) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.json(inspection);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deleteInspection = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const userId = req.appUser?.clerkId;
const userRole = req.appUser?.organizationRole || req.appUser?.role;
const isDeveloper = req.appUser?.email === 'admtracksteel@gmail.com';
const success = await inspectionService.deleteInspection(
req.params.id as string,
organizationId,
userId,
userRole as any,
isDeveloper
);
if (!success) return res.status(403).json({ error: 'Não autorizado ou não encontrado.' });
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getAllInspections = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const inspections = await inspectionService.getAllInspections(organizationId);
res.json(inspections);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const uploadPhoto = async (req: Request, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Return the public URL for the file
// Assuming 'uploads' is served statically at /uploads
const fileUrl = `/uploads/${req.file.filename}`;
res.json({ url: fileUrl });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,106 @@
import { Request, Response } from 'express';
import Instrument from '../models/Instrument.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
export const createInstrument = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const { name, type, manufacturer, modelName, serialNumber, calibrationDate, calibrationExpirationDate, certificateUrl, notes } = req.body;
const existing = await Instrument.findOne({ organizationId, serialNumber });
if (existing) {
return res.status(400).json({ error: 'Já existe um instrumento com este número de série.' });
}
// Determinar status inicial baseado na validade
let status = 'active';
if (calibrationExpirationDate && new Date(calibrationExpirationDate) < new Date()) {
status = 'expired';
}
const instrument = await Instrument.create({
organizationId,
name,
type,
manufacturer,
modelName,
serialNumber,
calibrationDate,
calibrationExpirationDate,
certificateUrl,
status,
notes
});
res.status(201).json(instrument);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getInstruments = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const { status } = req.query;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const query: any = { organizationId };
if (status) query.status = status;
const instruments = await Instrument.find(query).sort({ name: 1 });
res.json(instruments);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const updateInstrument = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
// Recalcular status se data de validade mudar
const updates = { ...req.body };
if (updates.calibrationExpirationDate) {
if (new Date(updates.calibrationExpirationDate) < new Date()) {
updates.status = 'expired';
} else if (updates.status === 'expired') {
// Se estava expirado e a data é futura, reativar (se o usuário não setou outro status)
updates.status = 'active';
}
}
const instrument = await Instrument.findOneAndUpdate(
{ _id: id, organizationId },
updates,
{ new: true }
);
if (!instrument) return res.status(404).json({ error: 'Instrumento não encontrado.' });
res.json(instrument);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deleteInstrument = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const deleted = await Instrument.findOneAndDelete({ _id: id, organizationId });
if (!deleted) return res.status(404).json({ error: 'Instrumento não encontrado.' });
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,244 @@
import { Request, Response } from 'express';
import Message from '../models/Message.js';
import OrganizationMember from '../models/OrganizationMember.js';
// Send a message
export const sendMessage = async (req: Request, res: Response) => {
try {
const { toUserId, message } = req.body;
const fromUserId = req.appUser?.clerkId;
const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' });
}
if (!fromUserId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
if (!toUserId || !message) {
return res.status(400).json({ error: 'Destinatário e mensagem são obrigatórios.' });
}
if (message.length > 255) {
return res.status(400).json({ error: 'Mensagem muito longa (máximo 255 caracteres).' });
}
// Check if there's already a pending (unread) message from this user to that user
const existingMessage = await Message.findOne({
organizationId,
fromUserId,
toUserId,
isRead: false,
});
if (existingMessage) {
// Update existing message instead of creating a new one
existingMessage.message = message;
existingMessage.updatedAt = new Date();
await existingMessage.save();
return res.json(existingMessage);
}
// Create new message
const newMessage = new Message({
organizationId,
fromUserId,
toUserId,
message,
});
await newMessage.save();
res.status(201).json(newMessage);
} catch (error) {
console.error('Error sending message:', error);
res.status(500).json({ error: 'Erro ao enviar mensagem.' });
}
};
// Get unread messages for current user
export const getUnreadMessages = async (req: Request, res: Response) => {
try {
const toUserId = req.appUser?.clerkId;
const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' });
}
if (!toUserId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
const messages = await Message.find({
organizationId,
toUserId,
isRead: false,
isArchived: false,
isDeletedByRecipient: false,
}).sort({ createdAt: -1 });
// Populate sender info
const messagesWithSender = await Promise.all(
messages.map(async (msg) => {
const sender = await OrganizationMember.findOne({ clerkUserId: msg.fromUserId });
return {
...msg.toObject(),
fromUser: sender ? { name: sender.name, email: sender.email } : null,
};
})
);
res.json(messagesWithSender);
} catch (error) {
console.error('Error getting unread messages:', error);
res.status(500).json({ error: 'Erro ao buscar mensagens.' });
}
};
// Mark message as read
export const markMessageAsRead = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = req.appUser?.clerkId;
const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' });
}
if (!userId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
const message = await Message.findOne({
_id: id,
organizationId,
toUserId: userId,
});
if (!message) {
return res.status(404).json({ error: 'Mensagem não encontrada.' });
}
message.isRead = true;
message.readAt = new Date();
await message.save();
res.json(message);
} catch (error) {
console.error('Error marking message as read:', error);
res.status(500).json({ error: 'Erro ao marcar mensagem como lida.' });
}
};
// Get my pending (unread) sent messages
export const getMyPendingMessages = async (req: Request, res: Response) => {
try {
const fromUserId = req.appUser?.clerkId;
const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' });
}
if (!fromUserId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
const messages = await Message.find({
organizationId,
fromUserId,
isRead: false,
}).sort({ createdAt: -1 });
// Populate recipient info
const messagesWithRecipient = await Promise.all(
messages.map(async (msg) => {
const recipient = await OrganizationMember.findOne({ clerkUserId: msg.toUserId });
return {
...msg.toObject(),
toUser: recipient ? { name: recipient.name, email: recipient.email } : null,
};
})
);
res.json(messagesWithRecipient);
} catch (error) {
console.error('Error getting pending messages:', error);
res.status(500).json({ error: 'Erro ao buscar mensagens pendentes.' });
}
};
// Delete a message (only if unread and sender is the current user)
export const deleteMessage = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = req.appUser?.clerkId;
const organizationId = req.headers['x-organization-id'] as string;
if (!organizationId) {
return res.status(400).json({ error: 'Organização não selecionada.' });
}
if (!userId) {
return res.status(401).json({ error: 'Usuário não autenticado.' });
}
const message = await Message.findOne({
_id: id,
organizationId,
fromUserId: userId,
isRead: false, // Can only delete unread messages
});
if (!message) {
return res.status(404).json({ error: 'Mensagem não encontrada ou já foi lida.' });
}
await message.deleteOne();
res.json({ message: 'Mensagem deletada com sucesso.' });
} catch (error) {
console.error('Error deleting message:', error);
res.status(500).json({ error: 'Erro ao deletar mensagem.' });
}
};
// Recipient deletes/archives a message
export const archiveMessage = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = req.appUser?.clerkId;
const organizationId = req.headers['x-organization-id'] as string;
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
message.isArchived = true;
message.isRead = true; // Arquivar implica ler
await message.save();
res.json(message);
} catch (error) {
console.error('Error archiving message:', error);
res.status(500).json({ error: 'Erro ao arquivar mensagem.' });
}
};
export const recipientDeleteMessage = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = req.appUser?.clerkId;
const organizationId = req.headers['x-organization-id'] as string;
const message = await Message.findOne({ _id: id, toUserId: userId, organizationId });
if (!message) return res.status(404).json({ error: 'Mensagem não encontrada.' });
message.isDeletedByRecipient = true;
await message.save();
res.json({ message: 'Mensagem excluída com sucesso.' });
} catch (error) {
console.error('Error deleting message:', error);
res.status(500).json({ error: 'Erro ao excluir mensagem.' });
}
};

View File

@@ -0,0 +1,97 @@
import { Request, Response } from 'express';
import { notificationService } from '../services/notificationService.js';
export const notificationController = {
getUserNotifications: async (req: Request, res: Response) => {
try {
const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string; // Assumindo que temos o ID do usuário (clerkId ou email)
// Se não tiver userId no header (ainda não implementado auth full), tentar pegar do query ou usar um fallback
// Nota: Idealmente o middleware de auth popula req.user. Vamos assumir que passamos x-user-id no frontend por enquanto.
if (!organizationId) {
return res.status(400).json({ error: 'Organization ID is required' });
}
const notifications = await notificationService.getUserNotifications(
userId,
organizationId,
req.query.includeArchived === 'true'
);
res.json(notifications);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error fetching notifications' });
}
},
markAsRead: async (req: Request, res: Response) => {
try {
const { id } = req.params;
const notification = await notificationService.markAsRead(id as string);
res.json(notification);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error marking notification as read' });
}
},
markAllAsRead: async (req: Request, res: Response) => {
try {
const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!organizationId) {
return res.status(400).json({ error: 'Organization ID is required' });
}
await notificationService.markAllAsRead(userId, organizationId);
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error marking all as read' });
}
},
clearAll: async (req: Request, res: Response) => {
try {
const organizationId = req.headers['x-organization-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!organizationId) {
return res.status(400).json({ error: 'Organization ID is required' });
}
await notificationService.clearAll(userId, organizationId);
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error clearing all notifications' });
}
},
archive: async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = req.headers['x-user-id'] as string;
const notification = await notificationService.archive(id as string, userId);
res.json(notification);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error archiving notification' });
}
},
delete: async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = req.headers['x-user-id'] as string;
await notificationService.softDelete(id as string, userId);
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Error deleting notification' });
}
}
};

View File

@@ -0,0 +1,73 @@
import { Request, Response } from 'express';
import * as paintingSchemeService from '../services/paintingSchemeService.js';
export const createPaintingScheme = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
console.log("Creating scheme with payload:", req.body);
const scheme = await paintingSchemeService.createPaintingScheme({ ...req.body, organizationId });
console.log("Created scheme result:", scheme);
res.status(201).json(scheme);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getPaintingSchemesByProject = async (req: Request, res: Response) => {
try {
const { projectId } = req.params;
const organizationId = req.appUser?.organizationId;
const schemes = await paintingSchemeService.getPaintingSchemesByProject(projectId as string, organizationId);
res.json(schemes);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const updatePaintingScheme = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
console.log("---------------------------------------------------");
console.log(`UPDATE REQUEST: ID=${req.params.id}`);
console.log(`User Org ID: ${organizationId}`);
console.log(`Payload keys: ${Object.keys(req.body)}`);
const scheme = await paintingSchemeService.updatePaintingScheme(req.params.id as string, req.body, organizationId);
console.log(`UPDATE RESULT: ${scheme ? 'SUCCESS' : 'NULL (Doc not found or not matched)'}`);
if (scheme) {
console.log(`Updated Doc Coat: ${scheme.coat}`);
}
console.log("---------------------------------------------------");
res.json(scheme);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deletePaintingScheme = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
await paintingSchemeService.deletePaintingScheme(req.params.id as string, organizationId);
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getAllPaintingSchemes = async (req: Request, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const schemes = await paintingSchemeService.getAllSchemes(organizationId);
res.json(schemes);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,70 @@
import { Request, Response } from 'express';
import * as partService from '../services/partService.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
export const createPart = async (req: AuthRequest, res: Response) => {
try {
console.log('[CREATE PART] Received data:', JSON.stringify(req.body, null, 2));
const organizationId = req.appUser?.organizationId;
const part = await partService.createPart({ ...req.body, organizationId });
console.log('[CREATE PART] Success:', part);
res.status(201).json(part);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('[CREATE PART] Error:', message);
res.status(500).json({ error: message });
}
};
export const getPartsByProject = async (req: AuthRequest, res: Response) => {
try {
const { projectId } = req.params;
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const parts = await partService.getPartsByProject(projectId as string, organizationId, isGlobalAdmin);
res.json(parts);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const updatePart = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const part = await partService.updatePart(req.params.id as string, req.body, organizationId, isGlobalAdmin);
res.json(part);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deletePart = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
await partService.deletePart(req.params.id as string, organizationId, isGlobalAdmin);
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getAllParts = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const parts = await partService.getAllParts(organizationId, isGlobalAdmin);
res.json(parts);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,107 @@
import { Request, Response } from 'express';
import * as projectService from '../services/projectService.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
export const createProject = async (req: AuthRequest, res: Response) => {
try {
console.log('Backend creating project. Body:', req.body);
const organizationId = req.appUser?.organizationId;
const project = await projectService.createProject({ ...req.body, organizationId });
console.log('Project created successfully:', project._id);
res.status(201).json(project);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getAllProjects = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const { status } = req.query;
const projects = await projectService.getAllProjects(organizationId, isGlobalAdmin, status as string);
res.json(projects);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const archiveProject = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const project = await projectService.archiveProject(req.params.id as string, organizationId, isGlobalAdmin);
res.json(project);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getDashboardProjects = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const projects = await projectService.getDashboardProjects(organizationId);
res.json(projects);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getProjectById = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const project = await projectService.getProjectById(req.params.id as string, organizationId, isGlobalAdmin);
res.json(project);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(404).json({ error: message });
}
};
export const updateProject = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
const project = await projectService.updateProject(req.params.id as string, req.body, organizationId, isGlobalAdmin);
// Notificação se Peso mudar (Exemplo simplificado, idealmente compararíamos com valor anterior)
// Como o update retorna o objeto atualizado, podemos assumir que se o body tem weightKg, houve intenção de mudar.
// Para ser mais preciso, deveríamos buscar o antigo antes, mas para MVP vamos notificar se houver o campo no body.
if (req.body.weightKg !== undefined && organizationId) {
await notificationService.create({
organizationId,
title: 'Atualização de Obra',
message: `O peso da obra "${project.name}" foi atualizado para ${project.weightKg}kg.`,
type: 'info',
metadata: { projectId: project._id, triggerType: 'project_update' }
});
}
res.json(project);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deleteProject = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const isGlobalAdmin = req.appUser?.email === 'admtracksteel@gmail.com';
await projectService.deleteProject(req.params.id as string, organizationId, isGlobalAdmin);
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,502 @@
import { Request, Response } from 'express';
import StockItem from '../models/StockItem.js';
import StockMovement from '../models/StockMovement.js';
import { IAppUser } from '../middleware/roleMiddleware.js';
import { notificationService } from '../services/notificationService.js';
interface AuthRequest extends Request {
appUser?: IAppUser;
}
export const createStockItem = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const {
dataSheetId,
rrNumber,
batchNumber,
quantity,
unit,
expirationDate,
notes,
color,
invoiceNumber,
receivedBy,
minStock
} = req.body;
// Validation
if (!dataSheetId || !rrNumber || !batchNumber || quantity === undefined || !unit) {
return res.status(400).json({ error: 'Campos obrigatórios: DataSheet, RR, Lote, Quantidade, Unidade.' });
}
// Check for duplicate RR within Org
const existing = await StockItem.findOne({ organizationId, rrNumber });
if (existing) {
return res.status(400).json({ error: `Já existe um item com o RR ${rrNumber}.` });
}
// --- Min Stock Inheritance Logic ---
let finalMinStock = Number(minStock) || 0;
// If user didn't provide a specific minStock (or provided 0), try to inherit from existing group
if (finalMinStock === 0) {
const existingGroupItem = await StockItem.findOne({
organizationId,
dataSheetId,
color
}).sort({ updatedAt: -1 }); // Get latest active config
if (existingGroupItem && existingGroupItem.minStock > 0) {
finalMinStock = existingGroupItem.minStock;
}
} else {
// If user DID provide a minStock, update all existing items in that group to match?
// User requested: "a regra de estoque minimo definido no cadastro precisa estar clonado para novos cadastros"
// And "soma dessas 'mesmas' tintas sejam comparadas com o estoque minimo cadastrado a elas"
// This implies the rule is a Property of the Group. So create/update should enforce consistency.
if (finalMinStock > 0) {
await StockItem.updateMany(
{ organizationId, dataSheetId, color },
{ $set: { minStock: finalMinStock } }
);
}
}
const newItem = new StockItem({
organizationId,
createdBy: req.appUser?.clerkId,
dataSheetId,
rrNumber,
batchNumber,
quantity: Number(quantity),
unit,
minStock: finalMinStock,
expirationDate,
notes,
color,
invoiceNumber,
receivedBy
});
const savedItem = await newItem.save();
// Create Initial Movement (ENTRY)
await StockMovement.create({
organizationId,
createdBy: req.appUser?.clerkId,
stockItemId: savedItem._id,
movementNumber: 1,
type: 'ENTRY',
quantity: Number(quantity),
responsible: userName,
notes: 'Abertura de Lote / Entrada Inicial'
});
// Notificação de Recebimento
if (organizationId) {
await notificationService.create({
organizationId,
title: 'Recebimento de Material',
message: `Recebido: ${quantity}${unit} de ${savedItem.rrNumber} (Lote: ${batchNumber}).`,
type: 'info',
metadata: { stockItemId: savedItem._id, triggerType: 'stock_received' }
});
}
// Check Low Stock immediately
await notificationService.checkLowStock(savedItem._id.toString());
res.status(201).json(savedItem);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error creating stock item:', error);
res.status(500).json({ error: message });
}
};
export const updateStockItem = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
// Only allow updating metadata, NOT quantity directly (quantity must be via adjustments)
// Adjusting logic: Admin might need to fix typo in quantity without movement record?
// Better enforcing movements. If quantity changes, user should use "Adjustment".
// Here we create a general update for details like Notes, Dates, etc.
const { quantity, ...otherData } = req.body; // Separate quantity
if (quantity !== undefined) {
return res.status(400).json({ error: 'Para alterar a quantidade, utilize as funções de Ajuste ou Consumo.' });
}
// Check if Min Stock is being updated
if (otherData.minStock !== undefined) {
const item = await StockItem.findOne({ _id: id, organizationId });
if (item) {
// Propagate to all siblings (same Product + Color)
await StockItem.updateMany(
{
organizationId,
dataSheetId: item.dataSheetId,
color: item.color
},
{ $set: { minStock: otherData.minStock } }
);
}
}
const updated = await StockItem.findOneAndUpdate(
{ _id: id, organizationId },
otherData,
{ new: true }
);
if (!updated) return res.status(404).json({ error: 'Item não encontrado.' });
// Check Low Stock (in case minStock changed)
await notificationService.checkLowStock(updated._id.toString());
res.json(updated);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const adjustStock = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const { quantityDelta, reason } = req.body; // quantityDelta: +10 or -5
if (!reason) return res.status(400).json({ error: 'Motivo é obrigatório para ajustes técnicos.' });
if (!quantityDelta || isNaN(quantityDelta)) return res.status(400).json({ error: 'Quantidade inválida.' });
const item = await StockItem.findOne({ _id: id, organizationId });
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
// Calculate new quantity
const newQuantity = Number(item.quantity) + Number(quantityDelta);
if (newQuantity < 0) return res.status(400).json({ error: 'Estoque insuficiente para este ajuste.' });
item.quantity = newQuantity;
await item.save();
// Calculate next movement number
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
const count = await StockMovement.countDocuments({ stockItemId: item._id });
const movementNumber = (lastMov?.movementNumber || count) + 1;
// Register Movement
await StockMovement.create({
organizationId,
createdBy: req.appUser?.clerkId,
stockItemId: item._id,
movementNumber,
type: 'ADJUSTMENT',
quantity: Number(quantityDelta),
responsible: userName,
reason
});
// Check Low Stock
await notificationService.checkLowStock(item._id.toString());
res.json(item);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const consumeStock = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const { quantityConsumed, requester, date } = req.body;
if (!requester) return res.status(400).json({ error: 'Solicitante é obrigatório.' });
if (!quantityConsumed || Number(quantityConsumed) <= 0) return res.status(400).json({ error: 'Quantidade deve ser maior que zero.' });
const item = await StockItem.findOne({ _id: id, organizationId });
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
if (item.quantity < Number(quantityConsumed)) return res.status(400).json({ error: 'Estoque insuficiente.' });
item.quantity -= Number(quantityConsumed);
await item.save();
// Calculate next movement number
const lastMov = await StockMovement.findOne({ stockItemId: item._id }).sort({ movementNumber: -1 });
const count = await StockMovement.countDocuments({ stockItemId: item._id });
const movementNumber = (lastMov?.movementNumber || count) + 1;
// Register Movement (Negative quantity for consumption)
await StockMovement.create({
organizationId,
createdBy: req.appUser?.clerkId,
stockItemId: item._id,
movementNumber,
type: 'CONSUMPTION',
quantity: -Number(quantityConsumed), // Negative
responsible: userName,
requester,
date: date || new Date()
});
// Check Low Stock
await notificationService.checkLowStock(item._id.toString());
res.json(item);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const deleteStockItem = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
// Optional: Block delete if there are movements other than ENTRY?
// For simplicity allow Admin to nuke it.
const deleted = await StockItem.findOneAndDelete({ _id: id, organizationId });
if (!deleted) return res.status(404).json({ error: 'Item não encontrado.' });
// Cleanup movements & logs
await StockMovement.deleteMany({ stockItemId: id });
await StockAuditLog.deleteMany({ stockItemId: id });
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockItems = async (req: AuthRequest, res: Response) => {
try {
const organizationId = req.appUser?.organizationId;
const { dataSheetId } = req.query;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const query: any = { organizationId };
if (dataSheetId) query.dataSheetId = dataSheetId;
// Sort by Expiration Date ASC (First to expire first)
const items = await StockItem.find(query)
.populate('dataSheetId', 'name manufacturer type')
.sort({ expirationDate: 1 });
res.json(items);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockItemById = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const item = await StockItem.findOne({ _id: id, organizationId })
.populate('dataSheetId', 'name manufacturer type');
if (!item) return res.status(404).json({ error: 'Item não encontrado.' });
res.json(item);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
export const getStockMovements = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params; // StockItem ID
const organizationId = req.appUser?.organizationId;
const movements = await StockMovement.find({ stockItemId: id, organizationId })
.sort({ date: -1 });
res.json(movements);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};
// ------------------------------------------------------------------
// CRUD & Auditing for Movements
// ------------------------------------------------------------------
import StockAuditLog from '../models/StockAuditLog.js';
export const updateStockMovement = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params; // Movement ID
const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const userId = req.appUser?.clerkId || 'system';
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
if (!isAdmin) {
return res.status(403).json({ error: 'Apenas administradores podem editar movimentações.' });
}
const { date, quantity, notes } = req.body;
const movement = await StockMovement.findOne({ _id: id, organizationId });
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
// Calculate Delta
// If quantity changed, we need to adjust the item balance
// Note: 'quantity' in movement is signed (+ for entry, - for consumption)
// If the user edits a Consumption (-10) to (-15), the val passed in body might be absolute or signed?
// Let's assume the frontend sends the SIGNED value consistent with the movement type?
// Actually best to stick to specific logic:
// If movement type is ENTRY/ADJUSTMENT, quantity is usually positive (unless neg adjustment).
// If CONSUMPTION, quantity is stored negative.
// Let's expect the frontend to send the 'raw' new value.
// Be careful: if frontend sends positive 10 for a consumption, we must flip it?
// Let's assume frontend sends the value exactly as it should be stored.
// HOWEVER, it's safer if we check type.
const newQuantitySigned = Number(quantity);
// Validation: Consumption should generally be negative, Entry positive.
// But for flexibility let's just trust the arithmetic diff for now,
// but warn if sign flips unexpectedly?
const oldQuantity = Number(movement.quantity);
const quantityDiff = newQuantitySigned - oldQuantity;
// Update Item
const newStockLevel = Number(item.quantity) + quantityDiff;
if (newStockLevel < 0) {
return res.status(400).json({ error: 'A alteração resultaria em estoque negativo.' });
}
item.quantity = newStockLevel;
await item.save();
// Audit Log
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
const typeLabel = typeMap[movement.type] || movement.type;
await StockAuditLog.create({
organizationId,
stockItemId: item._id,
movementId: movement._id,
movementNumber: movement.movementNumber,
userId,
userName,
action: 'UPDATE',
details: `Edição de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${oldQuantity} -> ${newQuantitySigned}`,
oldValues: { date: movement.date, quantity: movement.quantity, notes: movement.notes },
newValues: { date, quantity: newQuantitySigned, notes }
});
// Update Movement
movement.quantity = newQuantitySigned;
if (date) movement.date = date;
if (notes !== undefined) movement.notes = notes;
await movement.save();
res.json(movement);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error updating movement:', error);
res.status(500).json({ error: message });
}
};
export const deleteStockMovement = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const organizationId = req.appUser?.organizationId;
const userName = req.appUser?.name || req.appUser?.email || 'Unknown User';
const userId = req.appUser?.clerkId || 'system';
const isAdmin = req.appUser?.role === 'admin' || req.appUser?.organizationRole === 'admin';
if (!isAdmin) {
return res.status(403).json({ error: 'Apenas administradores podem excluir movimentações.' });
}
const movement = await StockMovement.findOne({ _id: id, organizationId });
if (!movement) return res.status(404).json({ error: 'Movimentação não encontrada.' });
const item = await StockItem.findOne({ _id: movement.stockItemId, organizationId });
if (!item) return res.status(404).json({ error: 'Item de estoque associado não encontrado.' });
// Reverse the effect
// If we delete an Entry (+10), we MUST subtract 10 from Item.
// If we delete a Consumption (-10), we MUST add 10 (subtract -10) to Item.
// So: Item.quantity -= movement.quantity
const reverseQty = Number(movement.quantity);
const newStockLevel = Number(item.quantity) - reverseQty;
if (newStockLevel < 0) {
return res.status(400).json({ error: 'A exclusão resultaria em estoque negativo.' });
}
item.quantity = newStockLevel;
await item.save();
// Audit Log
const typeMap: Record<string, string> = { ENTRY: 'ENTRADA', CONSUMPTION: 'CONSUMO', ADJUSTMENT: 'AJUSTE' };
const typeLabel = typeMap[movement.type] || movement.type;
await StockAuditLog.create({
organizationId,
stockItemId: item._id,
movementId: movement._id,
movementNumber: movement.movementNumber,
userId,
userName,
action: 'DELETE',
details: `Exclusão de Movimentação (#${movement.movementNumber || '?'} ${typeLabel}): Qtd ${movement.quantity}`,
oldValues: movement.toObject()
});
await StockMovement.deleteOne({ _id: id });
res.status(204).send();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error deleting movement:', error);
res.status(500).json({ error: message });
}
};
export const getStockAuditLogs = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params; // StockItem ID
const organizationId = req.appUser?.organizationId;
const logs = await StockAuditLog.find({ stockItemId: id, organizationId })
.sort({ timestamp: -1 });
res.json(logs);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
};

View File

@@ -0,0 +1,179 @@
import { Request, Response } from 'express';
import SystemSettings from '../models/SystemSettings.js';
import User from '../models/User.js';
import OrganizationMember from '../models/OrganizationMember.js';
import Organization from '../models/Organization.js';
import path from 'path';
import fs from 'fs';
import os from 'os';
export const getSettings = async (req: Request, res: Response) => {
try {
let settings = await SystemSettings.findOne({ settingsId: 'global' });
if (!settings) {
// Create default if not exists
settings = await SystemSettings.create({
settingsId: 'global',
appName: 'GPI',
appSubtitle: 'Gestão de Pintura Industrial'
});
}
res.json(settings);
} catch (error) {
console.error('Error fetching system settings:', error);
res.status(500).json({ error: 'Erro ao buscar configurações do sistema' });
}
};
export const updateSettings = async (req: Request, res: Response) => {
try {
const { appName, appSubtitle, appLogoUrl } = req.body;
const settings = await SystemSettings.findOneAndUpdate(
{ settingsId: 'global' },
{
appName,
appSubtitle,
appLogoUrl,
updatedBy: req.appUser?.email
},
{ new: true, upsert: true } // Create if not exists
);
console.log(`⚙️ System Settings updated by ${req.appUser?.email}`);
res.json(settings);
} catch (error) {
console.error('Error updating system settings:', error);
res.status(500).json({ error: 'Erro ao atualizar configurações do sistema' });
}
};
export const serveLogo = async (req: Request, res: Response) => {
try {
const { filename } = req.params as { filename: string };
// Check tmp dir first (Serverless/Netlify uploads)
const tmpPath = path.join(os.tmpdir(), 'uploads', filename);
// Check local dir (Development)
const localPath = path.join(process.cwd(), 'uploads', filename);
if (fs.existsSync(tmpPath)) {
res.sendFile(tmpPath);
} else if (fs.existsSync(localPath)) {
res.sendFile(localPath);
} else {
console.error(`Logo file not found in tmp or local: ${filename}`);
res.status(404).json({ error: 'Imagem não encontrada' });
}
} catch (error) {
console.error('Error serving logo:', error);
res.status(500).json({ error: 'Erro ao processar imagem' });
}
};
export const uploadLogo = async (req: Request, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Nenhum arquivo enviado.' });
}
// Return the API URL instead of static path
// This ensures requests go through /api proxy and we control serving
const fileUrl = `/api/system-settings/logo-image/${req.file.filename}`;
res.json({ url: fileUrl });
} catch (error) {
console.error('Error uploading logo:', error);
res.status(500).json({ error: 'Erro ao fazer upload do logo.' });
}
};
// Global Admin Functions
export const getGlobalUsers = async (req: Request, res: Response) => {
try {
const users = await User.find({}).sort({ createdAt: -1 });
res.json(users);
} catch (error) {
console.error('Error getting global users:', error);
res.status(500).json({ error: 'Erro ao buscar usuários globais.' });
}
};
export const getGlobalOrganizations = async (req: Request, res: Response) => {
try {
// Aggregate members to group by org and get full member lists
const organizations = await OrganizationMember.aggregate([
{
$group: {
_id: '$organizationId',
members: {
$push: {
name: '$name',
email: '$email',
role: '$role',
clerkUserId: '$clerkUserId',
isBanned: '$isBanned'
}
},
lastActive: { $max: '$updatedAt' }
}
},
{
$lookup: {
from: 'organizations', // Ensure this matches the collection name of Organization model
localField: '_id',
foreignField: 'clerkId',
as: 'orgDetails'
}
},
{
$unwind: {
path: '$orgDetails',
preserveNullAndEmptyArrays: true
}
},
{
$project: {
_id: 1,
lastActive: 1,
members: 1,
memberCount: { $size: '$members' },
isBanned: { $ifNull: ['$orgDetails.isBanned', false] },
name: { $ifNull: ['$orgDetails.name', ''] }
}
},
{ $sort: { memberCount: -1 } }
]);
res.json(organizations);
} catch (error) {
console.error('Error getting global organizations:', error);
res.status(500).json({ error: 'Erro ao buscar organizações globais.' });
}
};
export const toggleOrganizationBan = async (req: Request, res: Response) => {
try {
const { organizationId, isBanned } = req.body;
if (!organizationId) {
return res.status(400).json({ error: 'ID da organização é obrigatório.' });
}
// Upsert the Organization record
const org = await Organization.findOneAndUpdate(
{ clerkId: organizationId },
{ isBanned: isBanned },
{ new: true, upsert: true }
);
console.log(`Organization ${organizationId} ban status set to ${isBanned} by ${req.appUser?.email}`);
res.json(org);
} catch (error) {
console.error('Error toggling organization ban:', error);
res.status(500).json({ error: 'Erro ao atualizar status da organização.' });
}
};

Some files were not shown because too many files have changed in this diff Show More