Upload source code
This commit is contained in:
21
index.html
Normal file
21
index.html
Normal 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
73
package.json
Normal 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
42
src/client/App.css
Normal 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
122
src/client/App.tsx
Normal 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
BIN
src/client/assets/grade.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
1
src/client/assets/react.svg
Normal file
1
src/client/assets/react.svg
Normal 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 |
40
src/client/components/ActiveUsers.tsx
Normal file
40
src/client/components/ActiveUsers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
197
src/client/components/AdhesionGradeSelect.tsx
Normal file
197
src/client/components/AdhesionGradeSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
131
src/client/components/ArchivedNotificationsModal.tsx
Normal file
131
src/client/components/ArchivedNotificationsModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/client/components/Button.tsx
Normal file
39
src/client/components/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/client/components/Card.tsx
Normal file
37
src/client/components/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
36
src/client/components/ColorBubble.tsx
Normal file
36
src/client/components/ColorBubble.tsx
Normal 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')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/client/components/ConfirmModal.tsx
Normal file
66
src/client/components/ConfirmModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
src/client/components/Input.tsx
Normal file
28
src/client/components/Input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
418
src/client/components/Layout.tsx
Normal file
418
src/client/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
117
src/client/components/MobileList.tsx
Normal file
117
src/client/components/MobileList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
src/client/components/Modal.tsx
Normal file
30
src/client/components/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
199
src/client/components/NotificationBell.tsx
Normal file
199
src/client/components/NotificationBell.tsx
Normal 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;
|
||||||
112
src/client/components/PhotoUpload.tsx
Normal file
112
src/client/components/PhotoUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/client/components/ProtectedRoute.tsx
Normal file
48
src/client/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||||
|
};
|
||||||
36
src/client/components/Select.tsx
Normal file
36
src/client/components/Select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
154
src/client/components/SendMessageModal.tsx
Normal file
154
src/client/components/SendMessageModal.tsx
Normal 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ê já 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
210
src/client/components/TeamPresence.tsx
Normal file
210
src/client/components/TeamPresence.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
617
src/client/components/TechnicalManual.tsx
Normal file
617
src/client/components/TechnicalManual.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
src/client/components/Toast.tsx
Normal file
65
src/client/components/Toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
208
src/client/components/UnreadMessagesModal.tsx
Normal file
208
src/client/components/UnreadMessagesModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
322
src/client/components/admin/BackupRestore.tsx
Normal file
322
src/client/components/admin/BackupRestore.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
src/client/components/admin/GeometrySettings.tsx
Normal file
205
src/client/components/admin/GeometrySettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
225
src/client/components/forms/PaintingInspectionForm.tsx
Normal file
225
src/client/components/forms/PaintingInspectionForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
src/client/components/forms/SurfaceTreatmentForm.tsx
Normal file
157
src/client/components/forms/SurfaceTreatmentForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
93
src/client/components/modals/CloneSchemeModal.tsx
Normal file
93
src/client/components/modals/CloneSchemeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
463
src/client/components/modals/CreateControlRecordModal.tsx
Normal file
463
src/client/components/modals/CreateControlRecordModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
444
src/client/components/modals/CreateInspectionModal.tsx
Normal file
444
src/client/components/modals/CreateInspectionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
374
src/client/components/modals/CreatePaintingSchemeModal.tsx
Normal file
374
src/client/components/modals/CreatePaintingSchemeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
167
src/client/components/modals/CreatePartModal.tsx
Normal file
167
src/client/components/modals/CreatePartModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
215
src/client/components/modals/CreateProjectModal.tsx
Normal file
215
src/client/components/modals/CreateProjectModal.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
175
src/client/components/modals/DiluentListModal.tsx
Normal file
175
src/client/components/modals/DiluentListModal.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
150
src/client/components/modals/DiluentRegistrationModal.tsx
Normal file
150
src/client/components/modals/DiluentRegistrationModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
183
src/client/components/modals/ImportSchemeModal.tsx
Normal file
183
src/client/components/modals/ImportSchemeModal.tsx
Normal 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 já 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
377
src/client/components/modals/StockHistoryModal.tsx
Normal file
377
src/client/components/modals/StockHistoryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
268
src/client/components/modals/StockModal.tsx
Normal file
268
src/client/components/modals/StockModal.tsx
Normal 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 >
|
||||||
|
);
|
||||||
|
};
|
||||||
165
src/client/components/modals/StockOutModal.tsx
Normal file
165
src/client/components/modals/StockOutModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
186
src/client/components/reports/AnalyticalReport.tsx
Normal file
186
src/client/components/reports/AnalyticalReport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
174
src/client/components/reports/GeneralProjectReport.tsx
Normal file
174
src/client/components/reports/GeneralProjectReport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
270
src/client/components/reports/StockInventoryReport.tsx
Normal file
270
src/client/components/reports/StockInventoryReport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
src/client/context/AuthContext.tsx
Normal file
151
src/client/context/AuthContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
src/client/context/AuthContextType.ts
Normal file
17
src/client/context/AuthContextType.ts
Normal 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);
|
||||||
56
src/client/context/SystemSettingsContext.tsx
Normal file
56
src/client/context/SystemSettingsContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
22
src/client/context/ToastContext.tsx
Normal file
22
src/client/context/ToastContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
10
src/client/context/useAuth.ts
Normal file
10
src/client/context/useAuth.ts
Normal 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;
|
||||||
|
};
|
||||||
103
src/client/contexts/NotificationContext.tsx
Normal file
103
src/client/contexts/NotificationContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/client/contexts/NotificationContextState.ts
Normal file
16
src/client/contexts/NotificationContextState.ts
Normal 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);
|
||||||
10
src/client/hooks/useNotifications.ts
Normal file
10
src/client/hooks/useNotifications.ts
Normal 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;
|
||||||
|
};
|
||||||
58
src/client/hooks/usePresence.ts
Normal file
58
src/client/hooks/usePresence.ts
Normal 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 };
|
||||||
|
};
|
||||||
2
src/client/hooks/useToast.ts
Normal file
2
src/client/hooks/useToast.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export from the new context file to maintain backward compatibility
|
||||||
|
export { useToast } from '../context/ToastContext';
|
||||||
45
src/client/hooks/useUnreadMessages.ts
Normal file
45
src/client/hooks/useUnreadMessages.ts
Normal 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
219
src/client/index.css
Normal 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
47
src/client/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
|
|
||||||
612
src/client/pages/AdminDashboard.tsx
Normal file
612
src/client/pages/AdminDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
src/client/pages/AnalysisDashboard.tsx
Normal file
157
src/client/pages/AnalysisDashboard.tsx
Normal 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 (>30%) ou fora das normas. Risco de falha do revestimento.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
496
src/client/pages/CalculatorDashboard.tsx
Normal file
496
src/client/pages/CalculatorDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
694
src/client/pages/DataSheetLibrary.tsx
Normal file
694
src/client/pages/DataSheetLibrary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
551
src/client/pages/DeveloperDashboard.tsx
Normal file
551
src/client/pages/DeveloperDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
198
src/client/pages/GuestDashboard.tsx
Normal file
198
src/client/pages/GuestDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
344
src/client/pages/InspectionsList.tsx
Normal file
344
src/client/pages/InspectionsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
377
src/client/pages/InstrumentList.tsx
Normal file
377
src/client/pages/InstrumentList.tsx
Normal 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;
|
||||||
37
src/client/pages/Login.tsx
Normal file
37
src/client/pages/Login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
184
src/client/pages/OrganizationSelector.tsx
Normal file
184
src/client/pages/OrganizationSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
184
src/client/pages/PartsList.tsx
Normal file
184
src/client/pages/PartsList.tsx
Normal 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} m²` : '--'}</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
896
src/client/pages/ProjectDetails.tsx
Normal file
896
src/client/pages/ProjectDetails.tsx
Normal 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 >
|
||||||
|
);
|
||||||
|
};
|
||||||
584
src/client/pages/ProjectList.tsx
Normal file
584
src/client/pages/ProjectList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
210
src/client/pages/SchemesList.tsx
Normal file
210
src/client/pages/SchemesList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
442
src/client/pages/StockDashboard.tsx
Normal file
442
src/client/pages/StockDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1066
src/client/pages/YieldStudyDashboard.tsx
Normal file
1066
src/client/pages/YieldStudyDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
25
src/client/services/analysisService.ts
Normal file
25
src/client/services/analysisService.ts
Normal 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`);
|
||||||
|
};
|
||||||
79
src/client/services/api.ts
Normal file
79
src/client/services/api.ts
Normal 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;
|
||||||
37
src/client/services/dataSheetService.ts
Normal file
37
src/client/services/dataSheetService.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
22
src/client/services/geometryTypeService.ts
Normal file
22
src/client/services/geometryTypeService.ts
Normal 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', {});
|
||||||
|
};
|
||||||
88
src/client/services/stockService.ts
Normal file
88
src/client/services/stockService.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
75
src/client/services/systemSettingsService.ts
Normal file
75
src/client/services/systemSettingsService.ts
Normal 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;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
9
src/client/services/yieldStudyService.ts
Normal file
9
src/client/services/yieldStudyService.ts
Normal 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}`);
|
||||||
402
src/client/styles/reports.css
Normal file
402
src/client/styles/reports.css
Normal 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
226
src/client/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
19
src/client/types/Instrument.ts
Normal file
19
src/client/types/Instrument.ts
Normal 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>;
|
||||||
12
src/client/utils/toastHandler.ts
Normal file
12
src/client/utils/toastHandler.ts
Normal 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
67
src/server/app.ts
Normal 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;
|
||||||
46
src/server/config/database.ts
Normal file
46
src/server/config/database.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
153
src/server/controllers/analysisController.ts
Normal file
153
src/server/controllers/analysisController.ts
Normal 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 };
|
||||||
|
}
|
||||||
72
src/server/controllers/applicationRecordController.ts
Normal file
72
src/server/controllers/applicationRecordController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
285
src/server/controllers/dataSheetController.ts
Normal file
285
src/server/controllers/dataSheetController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
160
src/server/controllers/geometryTypeController.ts
Normal file
160
src/server/controllers/geometryTypeController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
117
src/server/controllers/inspectionController.ts
Normal file
117
src/server/controllers/inspectionController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
106
src/server/controllers/instrumentController.ts
Normal file
106
src/server/controllers/instrumentController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
244
src/server/controllers/messageController.ts
Normal file
244
src/server/controllers/messageController.ts
Normal 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.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
97
src/server/controllers/notificationController.ts
Normal file
97
src/server/controllers/notificationController.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
73
src/server/controllers/paintingSchemeController.ts
Normal file
73
src/server/controllers/paintingSchemeController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/server/controllers/partController.ts
Normal file
70
src/server/controllers/partController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
107
src/server/controllers/projectController.ts
Normal file
107
src/server/controllers/projectController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
502
src/server/controllers/stockController.ts
Normal file
502
src/server/controllers/stockController.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
179
src/server/controllers/systemSettingsController.ts
Normal file
179
src/server/controllers/systemSettingsController.ts
Normal 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
Reference in New Issue
Block a user