Restauração do código oficial do GPI-JWT-V3

This commit is contained in:
2026-03-18 21:55:33 +00:00
commit 405d121b0e
208 changed files with 38123 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.git
.agent
.antigravity
uploads
*.pdf

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules
dist
.env
.DS_Store
*.log
uploads/*
!uploads/.gitkeep
data/*.json
backend/data/*.json
.vite
coverage
.vercel

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"less.lint.unknownAtRules": "ignore"
}

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-slim
ARG MONGODB_URI=mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0
ARG JWT_SECRET=gpi_secure_prod_secret_2026_tracksteel_991827364500123567890abcdef
ARG COOLIFY_URL=https://gpi-jwt.reifonas.cloud
ARG COOLIFY_FQDN=gpi-jwt.reifonas.cloud
ARG COOLIFY_BRANCH=main
ARG COOLIFY_RESOURCE_UUID=wnb60qi3w0r849mos347szil
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/server/src/server/index.js"]

77
README.md Normal file
View File

@@ -0,0 +1,77 @@
# Gestor de Pintura Industrial (GPI)
Modern Full-Stack Application for managing industrial painting projects.
## Tech Stack
- **Backend**: Node.js, Express, Prisma, PostgreSQL.
- **Frontend**: React (Vite), Tailwind CSS, TypeScript.
## Prerequisites
- Node.js (v18+)
- PostgreSQL (running locally or via Docker)
## Setup
### Backend
1. Navigate to the backend directory:
```bash
cd backend
```
2. Install dependencies:
```bash
npm install
```
3. Configure Environment Variables:
- Copy `.env.example` (or create `.env`)
- Set `DATABASE_URL="postgresql://user:password@localhost:5432/gpidb?schema=public"`
4. Run Migrations:
```bash
npx prisma migrate dev --name init
```
5. Start the server:
```bash
npm run dev
```
Server runs on `http://localhost:3000`.
### Frontend
1. Navigate to the frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
App runs on `http://localhost:5173`.
## Features
- **Projects**: Create and manage painting projects.
- **Geometry**: Register parts with dimensions, area, and complexity.
- **Painting Schemes**: Define paint systems (Primer, Intermediate, Finish).
- **Application Control**: Log daily painting activities (Wet/Dry thickness calculation).
- **Inspection**: Record quality control inspections (EPS, Adhesion, Appearance).

2
VERCEL_FORCE_REFRESH.txt Normal file
View File

@@ -0,0 +1,2 @@
Force Refresh Vercel - Timestamp: 2026-01-25 13:55
Commit Hash Target: 30f8b5c

59
api/app.ts Normal file
View File

@@ -0,0 +1,59 @@
import express from 'express';
import cors from 'cors';
import projectRoutes from '../src/server/routes/projectRoutes.js';
import partRoutes from '../src/server/routes/partRoutes.js';
import paintingSchemeRoutes from '../src/server/routes/paintingSchemeRoutes.js';
import applicationRecordRoutes from '../src/server/routes/applicationRecordRoutes.js';
import inspectionRoutes from '../src/server/routes/inspectionRoutes.js';
import analysisRoutes from '../src/server/routes/analysisRoutes.js';
import dataSheetRoutes from '../src/server/routes/dataSheetRoutes.js';
import yieldStudyRoutes from '../src/server/routes/yieldStudyRoutes.js';
import userRoutes from '../src/server/routes/userRoutes.js';
import systemSettingsRoutes from '../src/server/routes/systemSettingsRoutes.js';
import geometryTypeRoutes from '../src/server/routes/geometryTypeRoutes.js';
import stockRoutes from '../src/server/routes/stockRoutes.js';
import authRoutes from '../src/server/routes/authRoutes.js';
import notificationRoutes from '../src/server/routes/notificationRoutes.js';
import instrumentRoutes from '../src/server/routes/instrumentRoutes.js';
import { extractUser } from '../src/server/middleware/roleMiddleware.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-organization-id', 'x-organization-name']
}));
app.use(express.json());
// Global Middleware
import { authMiddleware } from '../src/server/middleware/auth.js';
app.use(authMiddleware);
app.use(extractUser);
// Static Uploads
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
// Routes
app.use('/api/auth', authRoutes);
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/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.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
export default app;

27
api/db-test.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import mongoose from 'mongoose';
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI is missing from Vercel settings');
await mongoose.connect(uri);
const state = mongoose.connection.readyState;
await mongoose.disconnect();
res.json({
success: true,
message: 'MongoDB Connection verified!',
state: state === 1 ? 'Connected' : state
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
const stack = error instanceof Error ? error.stack : undefined;
res.status(500).json({
success: false,
error: message,
stack: stack
});
}
}

28
api/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from './app.js';
import mongoose from 'mongoose';
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
console.log('--- API CALL:', req.url);
// Inline connection to avoid external file dependency issues during boot
if (mongoose.connection.readyState !== 1) {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI environment variable is missing');
await mongoose.connect(uri);
}
// Use the localized app.js
return app(req, res);
} catch (error: unknown) {
console.error('SERVERLESS BOOT ERROR:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return res.status(500).json({
error: 'Serverless Boot Error',
message: message,
path: req.url,
suggestion: 'Check Vercel Logs for module resolution errors'
});
}
}

10
api/ping.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default function handler(req: VercelRequest, res: VercelResponse) {
res.json({
status: 'ok',
message: 'Vercel API is alive',
time: new Date().toISOString(),
env_check: process.env.MONGODB_URI ? 'URI present' : 'URI MISSING'
});
}

1655
app_pintura.html Normal file

File diff suppressed because it is too large Load Diff

30
check-pass.cjs Normal file
View File

@@ -0,0 +1,30 @@
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
const UserSchema = new mongoose.Schema({
email: String,
passwordHash: String
});
async function check() {
try {
await mongoose.connect(MONGODB_URI);
const User = mongoose.models.User || mongoose.model('User', UserSchema);
const user = await User.findOne({ email: 'admtracksteel@gmail.com' });
if (user) {
const isMatch = await bcrypt.compare('admin', user.passwordHash);
console.log('PASSWORD_MATCH_ADMIN:' + isMatch);
} else {
console.log('USER_NOT_FOUND');
}
} catch (err) {
console.error(err);
} finally {
await mongoose.disconnect();
process.exit(0);
}
}
check();

25
check-user.cjs Normal file
View File

@@ -0,0 +1,25 @@
const mongoose = require('mongoose');
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
const UserSchema = new mongoose.Schema({
email: String,
role: String,
passwordHash: String,
externalId: String
});
async function check() {
try {
await mongoose.connect(MONGODB_URI);
const User = mongoose.models.User || mongoose.model('User', UserSchema);
const user = await User.findOne({ email: 'admtracksteel@gmail.com' });
console.log('USER_CHECK_RESULT:' + JSON.stringify(user));
} catch (err) {
console.error(err);
} finally {
await mongoose.disconnect();
process.exit(0);
}
}
check();

49
check_db_clerk.cjs Normal file
View File

@@ -0,0 +1,49 @@
const mongoose = require('mongoose');
const uri = "mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0";
async function checkClerk() {
try {
console.log('Connecting to MongoDB...');
await mongoose.connect(uri);
console.log('Connected.');
const db = mongoose.connection.db;
const collections = await db.listCollections().toArray();
let foundClerkGlobal = false;
for (const collInfo of collections) {
const collection = db.collection(collInfo.name);
// Search for documents having clerkId or clerkUserId
const anyClerk = await collection.findOne({
$or: [
{ clerkId: { $exists: true } },
{ clerkUserId: { $exists: true } },
{ userId: /clerk/i },
{ email: /clerk/i }
]
});
if (anyClerk) {
console.log(`[!] Found Clerk-related data in collection: ${collInfo.name}`);
console.log('Example document keys:', Object.keys(anyClerk));
foundClerkGlobal = true;
} else {
console.log(`[ ] No obvious Clerk data in: ${collInfo.name}`);
}
}
if (!foundClerkGlobal) {
console.log('>>> NO CLERK DATA FOUND IN ANY COLLECTION <<<');
}
await mongoose.disconnect();
console.log('Done.');
} catch (err) {
console.error('Error:', err);
process.exit(1);
}
}
checkClerk();

57
docker-compose.yaml Normal file
View File

@@ -0,0 +1,57 @@
services:
wnb60qi3w0r849mos347szil-205710232375:
image: 'wnb60qi3w0r849mos347szil:2271946f1ee18791baac241ddde79b0fdd558ca0'
container_name: wnb60qi3w0r849mos347szil-205710232375
restart: unless-stopped
expose:
- '3000'
networks:
coolify:
aliases:
- wnb60qi3w0r849mos347szil-205710232375
mem_limit: '0'
memswap_limit: '0'
mem_swappiness: 60
mem_reservation: '0'
cpus: 0.0
cpu_shares: 1024
env_file:
- .env
labels:
- traefik.enable=true
- traefik.http.middlewares.gzip.compress=true
- traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
- traefik.http.routers.http-0-wnb60qi3w0r849mos347szil.entryPoints=http
- traefik.http.routers.http-0-wnb60qi3w0r849mos347szil.middlewares=redirect-to-https
- 'traefik.http.routers.http-0-wnb60qi3w0r849mos347szil.rule=Host(`gpi-jwt.reifonas.cloud`) && PathPrefix(`/`)'
- traefik.http.routers.http-0-wnb60qi3w0r849mos347szil.service=http-0-wnb60qi3w0r849mos347szil
- traefik.http.routers.https-0-wnb60qi3w0r849mos347szil.entryPoints=https
- traefik.http.routers.https-0-wnb60qi3w0r849mos347szil.middlewares=gzip
- 'traefik.http.routers.https-0-wnb60qi3w0r849mos347szil.rule=Host(`gpi-jwt.reifonas.cloud`) && PathPrefix(`/`)'
- traefik.http.routers.https-0-wnb60qi3w0r849mos347szil.service=https-0-wnb60qi3w0r849mos347szil
- traefik.http.routers.https-0-wnb60qi3w0r849mos347szil.tls.certresolver=letsencrypt
- traefik.http.routers.https-0-wnb60qi3w0r849mos347szil.tls=true
- traefik.http.services.http-0-wnb60qi3w0r849mos347szil.loadbalancer.server.port=3000
- traefik.http.services.https-0-wnb60qi3w0r849mos347szil.loadbalancer.server.port=3000
- 'caddy_0.encode=zstd gzip'
- 'caddy_0.handle_path.0_reverse_proxy={{upstreams 3000}}'
- 'caddy_0.handle_path=/*'
- caddy_0.header=-Server
- 'caddy_0.try_files={path} /index.html /index.php'
- 'caddy_0=https://gpi-jwt.reifonas.cloud'
- caddy_ingress_network=coolify
- coolify.managed=true
- coolify.version=4.0.0-beta.466
- coolify.applicationId=14
- coolify.type=application
- coolify.name=wnb60qi3w0r849mos347szil
- coolify.resourceName=gpi-jwt-v3
- coolify.projectName=app-tracksteel
- coolify.serviceName=gpi-jwt-v3
- coolify.environmentName=production
- coolify.pullRequestId=0
networks:
coolify:
external: true
name: coolify
attachable: true

27
eslint.config.js Normal file
View File

@@ -0,0 +1,27 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', '.vite', 'node_modules']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
project: ['./tsconfig.app.json', './tsconfig.node.json', './tsconfig.server.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
])

52
fix-admin-org.cjs Normal file
View File

@@ -0,0 +1,52 @@
const mongoose = require('mongoose');
const MONGODB_URI = 'mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0';
async function fix() {
try {
await mongoose.connect(MONGODB_URI);
// Define simple models for the script
const User = mongoose.models.User || mongoose.model('User', new mongoose.Schema({
email: String,
organizationId: String
}));
const Organization = mongoose.models.Organization || mongoose.model('Organization', new mongoose.Schema({
externalId: String,
name: String
}));
// 1. Garante que existe uma organização padrão
let org = await Organization.findOne({ externalId: 'default-org' });
if (!org) {
org = await Organization.create({
externalId: 'default-org',
name: 'Default Organization'
});
console.log('✅ Organização default-org criada.');
} else {
console.log('✅ Organização default-org já existe.');
}
// 2. Vincula o admin à organização
const email = 'admtracksteel@gmail.com';
const result = await User.updateOne(
{ email },
{ $set: { organizationId: 'default-org' } }
);
if (result.matchedCount > 0) {
console.log(`✅ Usuário ${email} vinculado à organização default-org.`);
} else {
console.log(`❌ Usuário ${email} não encontrado.`);
}
} catch (err) {
console.error(err);
} finally {
await mongoose.disconnect();
process.exit(0);
}
}
fix();

7
id_antigravity Normal file
View File

@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVogAAAJAeNjMSHjYz
EgAAAAtzc2gtZWQyNTUxOQAAACAp2sxQE/eO30etME9tLKJjguVPUg3W8k3j2H7F/kBVog
AAAEDLm78AwM6lbNoz7iVUh1xvlphZzNhitquW4jHyR7lIhCnazFAT947fR60wT20somOC
5U9SDdbyTePYfsX+QFWiAAAAC2FudGlncmF2aXR5AQI=
-----END OPENSSH PRIVATE KEY-----

1
id_antigravity.pub Normal file
View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnazFAT947fR60wT20somOC5U9SDdbyTePYfsX+QFWi antigravity

21
index.html Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

41
migrate_clerk_fields.cjs Normal file
View File

@@ -0,0 +1,41 @@
const mongoose = require('mongoose');
const uri = "mongodb+srv://admtracksteel:29OHAHpKTI8XcCNt@cluster0.a4xiilu.mongodb.net/ts_gpi?retryWrites=true&w=majority&appName=Cluster0";
async function migrateDB() {
try {
console.log('Connecting to MongoDB...');
await mongoose.connect(uri);
console.log('Connected.');
const db = mongoose.connection.db;
// 1. Rename clerkId to externalId in users
console.log('Migrating Users...');
await db.collection('users').updateMany(
{ clerkId: { $exists: true } },
{ $rename: { "clerkId": "externalId" } }
);
// 2. Rename clerkUserId to userId in organizationmembers
console.log('Migrating OrganizationMembers...');
await db.collection('organizationmembers').updateMany(
{ clerkUserId: { $exists: true } },
{ $rename: { "clerkUserId": "userId" } }
);
// 3. Rename clerkId to externalId in organizations
console.log('Migrating Organizations...');
await db.collection('organizations').updateMany(
{ clerkId: { $exists: true } },
{ $rename: { "clerkId": "externalId" } }
);
console.log('Migration completed successfully.');
await mongoose.disconnect();
} catch (err) {
console.error('Migration failed:', err);
process.exit(1);
}
}
migrateDB();

27
netlify.toml Normal file
View File

@@ -0,0 +1,27 @@
[build]
command = "npm run build:client"
publish = "dist"
[functions]
directory = "netlify/functions"
node_bundler = "esbuild"
included_files = ["src/server/**"]
# Redirecionar chamadas de API para a Serverless Function
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/api/:splat"
status = 200
force = true
# Redirecionar uploads para a function também (pois o Express serve estáticos em /uploads, embora em serverless isso seja efêmero/lento, é o fallback)
[[redirects]]
from = "/uploads/*"
to = "/.netlify/functions/api/uploads/:splat"
status = 200
# Wildcard para SPA (React Router) - deve ser o último
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

72
netlify/functions/api.ts Normal file
View File

@@ -0,0 +1,72 @@
console.log('Loading Netlify Function...');
import serverless from 'serverless-http';
import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose';
// Import local database connection that sets up GridFS/Bucket
import { connectDB } from '../../src/server/config/database.js';
// Static imports for routes to ensure esbuild bundles them correctly
import projectRoutes from '../../src/server/routes/projectRoutes.js';
import partRoutes from '../../src/server/routes/partRoutes.js';
import paintingSchemeRoutes from '../../src/server/routes/paintingSchemeRoutes.js';
import applicationRecordRoutes from '../../src/server/routes/applicationRecordRoutes.js';
import inspectionRoutes from '../../src/server/routes/inspectionRoutes.js';
import analysisRoutes from '../../src/server/routes/analysisRoutes.js';
import dataSheetRoutes from '../../src/server/routes/dataSheetRoutes.js';
import yieldStudyRoutes from '../../src/server/routes/yieldStudyRoutes.js';
import userRoutes from '../../src/server/routes/userRoutes.js';
import uploadsRoutes from '../../src/server/routes/uploadsRoutes.js';
import systemSettingsRoutes from '../../src/server/routes/systemSettingsRoutes.js';
import geometryTypeRoutes from '../../src/server/routes/geometryTypeRoutes.js';
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-clerk-user-id', 'x-organization-id']
}));
app.use(express.json());
// Routes
// We register them immediately since we use serverless-http's cold start handling
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/yield-studies', yieldStudyRoutes);
app.use('/api/system-settings', systemSettingsRoutes);
app.use('/api/geometry-types', geometryTypeRoutes);
// Serve uploads (from /tmp in serverless or local dir)
app.use('/uploads', uploadsRoutes);
// Simple test endpoint
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date(),
mongoState: mongoose.connection.readyState
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const handler = async (event: any, context: any) => {
context.callbackWaitsForEmptyEventLoop = false;
// Ensure DB is connected before processing request
try {
await connectDB();
} catch (e) {
console.error('DB Connection Failed:', e);
// We let it proceed, maybe it's a health check or will fail gracefully later
}
const httpHandler = serverless(app);
return await httpHandler(event, context);
};

11891
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
package.json Normal file
View File

@@ -0,0 +1,75 @@
{
"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": {
"@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",
"bcryptjs": "^3.0.3",
"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",
"jsonwebtoken": "^9.0.3",
"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/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@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"
}
}

View File

@@ -0,0 +1,294 @@
# Variáveis do Processo de Pintura Industrial: Uma Análise Abrangente para Estruturas Metálicas
Este documento fornece uma análise profunda e detalhada de todas as variáveis que participam ativamente do processo de pintura industrial em estruturas metálicas, desde a preparação inicial da superfície até a inspeção final de aderência e espessura de camada de tinta. A qualidade e durabilidade de um sistema de pintura industrial dependem fundamentalmente do controle rigoroso dessas variáveis em cada etapa do processo, conforme estabelecido pelas normas brasileiras ABNT e pelas normas internacionais ISO, SSPC, NACE e pelas normas técnicas Petrobras.
## 1. Preparação da Superfície: A Etapa Crítica do Processo
A preparação da superfície é unanimemente reconhecida como a etapa mais crítica de todo o processo de pintura industrial. Estudos científicos demonstram que entre 80% e 90% do sucesso de um sistema de pintura depende da qualidade do preparo da superfície metálica, muito mais do que da qualidade das tintas ou métodos de aplicação. A falha em qualquer um dos passos desta fase inicial pode comprometer toda a longevidade e eficácia do revestimento, independentemente dos produtos e técnicas sofisticados utilizados posteriormente.
### 1.1 Inspeção Visual Inicial
Antes de qualquer tratamento mecânico ou químico, a superfície deve ser avaliada sistematicamente através de inspeção visual que segue os padrões fotográficos definidos pela ISO 8501-1 e ABNT NBR 14847. Esta inspeção inicial determina qual estratégia de preparação será mais adequada e eficiente. O primeiro parâmetro avaliado é o **grau de oxidação ou corrosão** da superfície, que pode variar desde uma condição praticamente nova (Grau A) até corrosão generalizada (Grau D). Superfícies com diferentes graus de corrosão exigem diferentes intensidades de limpeza: uma superfície ligeiramente oxidada pode ser adequadamente preparada com ferramentas manuais (Grau St 2), enquanto uma com corrosão profunda ou existência de várias camadas de tinta anterior requer jateamento abrasivo industrial (Grau Sa 2,5 ou Sa 3).
A presença de **carepa de laminação** é outra variável crítica observada na inspeção visual. Esta é uma camada óxida frágil formada durante a fabricação do aço e que deve ser completamente removida, pois prejudica a aderência da tinta ao substrato. Sua remoção requer limpeza mais intensiva do que seria necessária para apenas remover ferrugem. Além disso, devem ser inspecionados **resíduos de tintas anteriores**, que podem estar parcialmente aderidas, descascadas ou em diferentes estados de degradação. Cada condição exige uma abordagem diferenciada: tintas antigas bem aderidas podem ser limpas com jato leve e repintadas sobre a camada existente, enquanto tintas descascadas devem ser removidas até o metal nu.
### 1.2 Métodos de Limpeza Química
A limpeza química é frequentemente a primeira etapa prática do processo, especialmente quando óleo, graxa ou outros contaminantes orgânicos estão presentes. O **desengraxamento** deve ser realizado utilizando produtos específicos aprovados pela ABNT NBR 15158, que definem os procedimentos para remoção de óleos e graxas sem danificar a superfície. Este passo é absolutamente essencial, pois nenhuma quantidade de limpeza mecânica posterior pode remover óleos que tenham penetrado microscopicamente na superfície.
Quando ferrugem ou óxidos já estão presentes, utiliza-se um **converter de ferrugem**, que é um produto químico que converte os óxidos de ferro em compostos insolúveis que formam uma camada protetora e melhoram a aderência da tinta. No entanto, a escolha correta do conversor é crítica, pois um produto inadequado pode deixar resíduos incompatíveis com o sistema de pintura posteriormente aplicado.
### 1.3 Métodos de Limpeza Mecânica e Abrasiva
A **limpeza mecânica com ferramentas manuais** (escovamento manual com escova de aço, lixamento com lixas) representa o método menos agressivo e mais inadequado para preparação estrutural profunda. Este método produz um grau de preparação designado como St 2, caracterizado por uma superfície áspera com brilho metálico, adequado apenas para superfícies com pouca corrosão e tintas antigas ligeiramente aderidas. A profundidade de penetração é limitada, não removendo adequadamente a corrosão ou criando um perfil de rugosidade significativo.
O **jateamento abrasivo** (ou abrasive blast cleaning) é o método mais eficaz e amplamente utilizado na indústria, realizado conforme especificações SSPC-SP 6 (jato ligeiro/brush-off), SSPC-SP 10 (jato comercial), SSPC-SP 11 (jato mecânico ao metal nu) e SSPC-SP 14 (jato industrial/white blast). O processo pode ser seco ou úmido, com diferentes tipos de abrasivos: areia natural, granalha de aço (angular ou esférica), óxido de alumínio sinterizado ou outros materiais. O tipo de abrasivo afeta significativamente o resultado final.
A **granulometria do abrasivo** é controlada pelos padrões estabelecidos pela ABNT NBR 16267, que especificam que a areia deve passar pela peneira nº 12 (ASTM E 11) e ser retida na peneira nº 40. A granulometria inadequada resulta em perfil de rugosidade não conforme.
Um fator absolutamente crítico é o **perfil de rugosidade** produzido pelo jateamento, medido em micrômetros (μm). A ISO 8503-1 e ABNT NBR 10443 especificam que o perfil mínimo de rugosidade deve ser de 25 μm para um bom sistema de pintura. Este perfil funciona como uma microscópica "paisagem de ancoragem" na qual a tinta líquida penetra e, ao curar, forma uma ligação mecânica robusta com o substrato. Um perfil insuficiente resulta em aderência reduzida e envelhecimento prematuro do revestimento, enquanto um perfil excessivamente agressivo (acima de 100 μm) pode criar picos frágeis que se oxidam rapidamente antes da aplicação de tinta.
A **duração da proteção temporária** após jateamento é outra variável crítica. O aço recém-jateado é extremamente reativo, exposto a uma superfície completamente limpa de óxidos protetores. Normas como a PETROBRAS N-9 e SSPC especificam que a pintura deve ser aplicada em prazos bem definidos após o jateamento: entre 24 e 72 horas em ambientes terrestres (dependendo da umidade relativa do ar), pois passado este tempo pode ocorrer formação de "flash rust" (corrosão branca de zinco).
### 1.4 Hidrojateamento
O **hidrojateamento** (water jetting ou high-pressure water cleaning) é um método que utiliza água a altíssimas pressões (tipicamente 700-2800 bar para hidrojateamento de ultra-alta pressão) conforme SSPC-SP 13 e normas ISO 8502-9. Este método é particularmente eficaz para remover materiais soltos, tintas degradadas, produtos de corrosão e contaminantes, especialmente em superfícies já pintadas onde se deseja remover apenas as camadas danificadas. No entanto, o hidrojateamento apresenta uma limitação importante: não produz um perfil de rugosidade adequado em aço novo com carepa de laminação, sendo mais apropriado como complemento ao jateamento abrasivo.
Uma variável crítica no hidrojateamento é o **controle da pressão de água** durante o processo. Pressão insuficiente deixa contaminantes não removidos, enquanto pressão excessiva pode danificar estruturas metálicas ou criar deformações locais. A água utilizada deve ser de qualidade controlada (sem sais e contaminantes), pois água de poço ou marinha deixaria contaminantes que prejudicariam a futura aderência.
### 1.5 Análise de Contaminantes Superficiais
Após qualquer método de preparação, a superfície pode estar contaminada por materiais não visíveis a olho nu que comprometem drasticamente a durabilidade do revestimento. Os **sais solúveis** (cloretos, sulfatos, nitratos) são particularmente insidiosos porque são invisíveis mas causam uma falha específica do revestimento chamada **empolamento osmótico**, onde a umidade é atraída através do filme de tinta pelos sais, criando pressão osmótica que separa a tinta do substrato. A ISO 8502-6 e a ISO 8502-9 especificam que a quantidade máxima aceitável é tipicamente inferior a 5 mg/m² de cloreto, medida pelo método Bresle. Este teste envolve colocar uma célula adesiva com água purificada sobre a superfície jateada, permitindo que a água dissolva os sais presentes, e então medir a condutividade da solução extraída.
O **óleo e graxa residual** deve ser completamente removido em todas as superfícies, particularmente em ambientes com manutenção industrial anterior. Resíduos de óleo de máquinas, óleos desmoldantes de formas de concreto ou outras gorduras podem ser invisíveis mas prejudicam a aderência. A ISO 8502-3 define testes de poeira residual, enquanto a ABNT NBR 14847 especifica procedimentos de limpeza adequados.
## 2. Preparação das Tintas e Materiais de Aplicação
Após a preparação adequada da superfície, o sucesso subsequente do sistema de pintura depende criticamente de como as tintas são preparadas e diluídas. Pequenas variações em parâmetros como sólidos por volume, percentual de diluição e viscosidade podem resultar em diferenças significativas de rendimento, espessura de filme seco e propriedades finais do revestimento.
### 2.1 Características Fundamentais das Tintas
As tintas utilizadas em pintura industrial de estruturas metálicas podem ser classificadas em diferentes tipos genéricos, cada um com propriedades distintas. As **tintas epóxi** (geralmente bicomponentes) são caracterizadas por alta resistência química e física, excelente aderência e impermeabilidade, mas com limitação importante: apresentam baixa resistência aos raios ultravioleta (UV), tornando-se amareladas e perdendo brilho quando expostas ao sol direto. Por esta razão, tintas epóxi são ideais para ambientes internos, submersos ou com sombra permanente. O tempo de cura tipicamente é longo, variando de 6 a 16 horas para repintura e até 168 horas (7 dias) para cura completa a 25°C.
As **tintas poliuretano** (PU) oferecem resistência superior aos raios UV e choques térmicos, mantendo a cor e o brilho por períodos muito mais longos. Possuem excelente resistência à abrasão e água, sendo ideais para ambientes externos, tubulações expostas, tanques e estruturas marinhas. O tempo de cura é mais rápido que epóxi: secagem ao toque em 1 a 4 horas, repintura possível entre 4 a 24 horas, mas cura final ainda requer 7 a 10 dias a 25°C.
As **tintas de silicato inorgânico de zinco** (etil silicato com pigmento de pó de zinco) funcionam de maneira diferente, oferecendo proteção catódica ativa ao aço carbono através da oxidação preferencial do zinco. Estas tintas requerem aplicação em ambientes com umidade relativa adequada (não acima de 80-85%), pois alta umidade interfere na formação da película. Uma variável importante específica a estas tintas é que sua polimerização requer saída controlada de álcool etílico durante a cura, e água excessiva prejudica este processo.
### 2.2 Sólidos por Volume e Rendimento Teórico
O **sólidos por volume (SV)** é talvez o parâmetro mais importante para compreender o desempenho econômico e prático de uma tinta. Definido como a porcentagem do volume de tinta que permanece como filme sólido após a evaporação completa de todos os solventes, o SV afeta diretamente o rendimento teórico (m²/L) conforme a fórmula:
`Rendimento Teórico (m²/L) = (SV% × 10) / Espessura de Película Seca (μm)`
Por exemplo, uma tinta com 30% de sólidos por volume aplicada para atingir 50 μm de espessura seca renderá aproximadamente 6 m²/L. Se a mesma tinta for diluída em 15%, seu novo SV efetivo será reduzido, diminuindo o rendimento. Este cálculo é essencial para orçamentos precisos e controle de desperdício.
A importância do SV transcende o aspecto econômico: tintas com baixo SV exigem maior diluição para aplicação adequada, o que reduz a quantidade de material protetor por unidade de volume de líquido aplicado. Normas internacionais recomendam que as tintas não sejam diluídas acima de 10-20% conforme especificado pelo fabricante, pois diluições excessivas reduzem drasticamente a espessura de película seca obtida e, consequentemente, a proteção.
### 2.3 Diluição e Viscosidade
A **diluição da tinta** é um passo que parece simples mas requer controle rigoroso. O solvente apropriado deve ser selecionado de acordo com o tipo de tinta: cetonas, aromáticos, alifáticos ou solventes específicos recomendados pelo fabricante. Adicionar um solvente inadequado (por exemplo, thinner para tintas à base de água) resulta em reações químicas, aglomeração de pigmentos e falha total do revestimento.
A **viscosidade** da tinta diluída deve atingir um ponto ótimo para a aplicação desejada. Muito espessa, a tinta não flui adequadamente e deixa marcas de pincel/rolo ou padrão inadequado da pistola. Muito fina, a tinta não mantém cobertura uniforme e pode escorrer. O método padrão de medição de viscosidade é o **viscosímetro copo Ford**, particularmente o copo Ford nº 4, onde o tempo de escoamento completo deve estar entre 30-50 segundos para aplicação ótima. Uma medição rápida e repetida em campo permite ajustes imediatos antes da aplicação.
### 2.4 Tintas Bicomponentes e Tempo de Indução
Muitas tintas industriais são **tintas bicomponentes** (A+B), onde o componente A contém a resina base e o componente B contém o agente de cura. O momento exato de mistura é crítico. A maioria dos fabricantes especifica um **tempo de indução** (15-20 minutos a 25°C) após a mistura de A+B, antes de iniciar a aplicação. Este tempo permite a homogeneização completa dos componentes. Começar a aplicar muito rapidamente pode resultar em propriedades inadequadas de cura, enquanto esperar muito tempo além do especificado pode resultar em aumento de viscosidade prematuro ou até gelificação da mistura.
A **vida útil da mistura** (pot life) é outra variável crítica: a maioria das tintas epóxi bicomponentes tem pot life de 4 horas a 25°C. Após este tempo, a tinta começa a espessar e tornar-se inadequada para aplicação. Em ambientes mais quentes, o pot life pode reduzir para 2-3 horas, exigindo preparação em quantidades menores.
### 2.5 Temperatura da Tinta em Aplicação
A **temperatura da tinta** deve estar compreendida idealmente entre 16°C e 30°C no momento da aplicação. Tintas muito frias (abaixo de 16°C) apresentam viscosidade excessivamente elevada mesmo após diluição, exigindo excesso de solvente para atingir viscosidade adequada, o que reduz drasticamente o SV efetivo. Tintas muito quentes (acima de 30°C) perdem solventes por evaporação acelerada, o que pode causar aumento rápido de viscosidade durante a aplicação e padrões inadequados de pulverização.
## 3. Variáveis de Ambiente e Condições Atmosféricas
As condições ambientais exercem influência profunda em cada aspecto da pintura industrial. Ignorar estas variáveis é a causa raiz de inúmeras falhas de revestimento em campo.
### 3.1 Temperatura do Ar Ambiente
A **temperatura do ar ambiente** ideal para aplicação é entre 16°C e 30°C. Abaixo de 16°C (até o mínimo de 5-10°C com técnicas especiais), a velocidade de cura diminui significativamente. Uma tinta epóxi que cura em 8 horas a 25°C pode levar 16 horas a 15°C e até 24+ horas a 5°C. Isto tem implicações práticas: intervalos entre demãos podem ultrapassar as 48 horas, retardando cronogramas, e a proteção contra contaminação ambiental é reduzida durante este período prolongado.
Acima de 30°C (até 40°C com técnicas especiais), a evaporação de solventes é acelerada, frequentemente causando "spray seco" em aplicações com pistola, onde a tinta seca parcialmente no ar antes de atingir a superfície. Isso resulta em acabamento áspero, sem brilho e com cobertura inadequada.
### 3.2 Temperatura da Superfície
A **temperatura da superfície** (temperatura do aço ou estrutura a ser pintada) é frequentemente diferente da temperatura do ar ambiente, particularmente em aplicações outdoor. Estruturas expostas ao sol direto podem estar 15-20°C acima da temperatura do ar, enquanto estruturas na sombra noturna podem estar alguns graus abaixo. A importância desta diferença reside no fato de que a **taxa de cura e saída de solventes** ocorre na interface superfície/tinta, não no ar ambiente. Uma superfície muito quente acelera excessivamente a cura, enquanto uma muito fria a retarda.
### 3.3 Umidade Relativa do Ar
A **umidade relativa (UR)** do ar é uma variável crítica e frequentemente subestimada. O intervalo ideal é 30-60%, nunca acima de 80-85%. A razão reside na relação direta entre umidade relativa e condensação: uma superfície metálica fria pode ficar abaixo do ponto de orvalho (temperatura de saturação), permitindo que água condense na superfície. Se esta condensação ocorre antes ou durante a aplicação da tinta, a tinta aprisionará a umidade contra o substrato, resultando em empolamento, corrosão e falha prematura do revestimento.
Em regiões muito úmidas (como Manaus, com UR frequentemente acima de 80%), o padrão de tinta deve ser selecionado especificamente para estas condições. Por exemplo, primers de epóxi endurecido com amidas são menos sensíveis a umidade que aqueles endurecidos com poliaminas. Tintas de silicato inorgânico de zinco tradicionais têm dificuldade de formação de película em alta umidade, enquanto versões à base de silicato de etila formam película mais facilmente.
### 3.4 Ponto de Orvalho e Condensação
O **ponto de orvalho** (dew point) é a temperatura à qual a umidade do ar começará a se condensar em uma superfície. É função tanto da temperatura do ar quanto da umidade relativa. As normas ISO 8502-4 e SSPC especificam que a **temperatura da superfície deve estar no mínimo 3°C acima do ponto de orvalho** em todas as fases críticas: preparação, aplicação e cura.
Por exemplo: se em um dia o ar está a 20°C com 70% de umidade relativa, o ponto de orvalho calculado é aproximadamente 13°C. Isto significa que nenhuma aplicação de tinta deveria ocorrer se a superfície metálica estiver abaixo de 16°C (13°C + 3°C de margem de segurança). Esta margem de 3°C é considerada crítica porque quando os solventes da tinta evaporam, resfriam a superfície; esta margem compensa este efeito de resfriamento.
## 4. Métodos de Aplicação e Variáveis de Pulverização
A seleção do método de aplicação correto e o controle rigoroso de seus parâmetros têm impacto decisivo no acabamento final e na taxa de sucesso da pintura.
### 4.1 Aplicação com Pistola Airless
O **método airless** (ou airless spray) é amplamente utilizado em pintura industrial de estruturas metálicas porque combina alta produtividade com acabamento adequado. Ao contrário de pistolas convencionais que usam ar comprimido para atomizar a tinta, sistemas airless bombeiam a tinta a alta pressão (tipicamente 2000-7250 psi ou 140-500 bar) através de um bico fino, onde a pressão do fluido realiza a atomização.
Os parâmetros críticos de uma pistola airless incluem:
- **Pressão de pulverização (psi ou bar)**: afeta o tamanho da gotícula, padrão de pulverização e acabamento. Pressão insuficiente resulta em gotículas grandes, acabamento áspero e possível "descontinuidade de aplicação" (gaps). Pressão excessiva causa névoa de pulverização, desperdício de tinta e possível sobre-aquecimento da bomba.
- **Vazão de tinta (L/min)**: afeta diretamente a taxa de cobertura. Vazões típicas variam de 0.5 a 2.0 L/min, dependendo do tamanho do bico e tipo de tinta. Viscosidade inadequada para a vazão selecionada resulta em padrão inadequado.
- **Tamanho do bico pulverizador**: determina o tamanho mínimo de gotícula possível. Bicos menores (0.009-0.013 polegadas) produzem acabamento mais fino mas são propensos a entupimento com tintas viscosas. Bicos maiores (0.017-0.021 polegadas) aceitam tintas mais viscosas mas produzem gotículas maiores.
A vantagem principal do sistema airless sobre sistemas convencionais é o **menor overspray** (névoa de tinta perdida no ar), resultando em economia de material e menor desperdício ambiental.
### 4.2 Pistolas Convencionais e Variantes HVLP/LVLP
As **pistolas de ar comprimido convencionais** utilizam ar a pressão (típico 25-40 psi) para atomizar a tinta. Requerem um compressor de ar de volume adequado, tipicamente acima de 100 litros para manter pressão constante durante aplicação. A vantagem é excelente qualidade de acabamento, mas apresentam maior overspray (típico 50-70% de desperdício) comparado a sistemas airless.
As pistolas **HVLP** (High Volume, Low Pressure) utilizam grande volume de ar a baixa pressão (10-20 psi) para melhor controle da atomização e redução de overspray para 25-35%. São especialmente valiosas para acabamentos de alta qualidade, reparação automotiva e aplicações em superfícies verticais onde o controle preciso é essencial.
As pistolas **LVLP** (Low Volume, Low Pressure) são intermediárias, utilizando menor volume de ar que HVLP, com consumo de ar mais eficiente e produção de menos névoa ambiental, tornando-as ideais para trabalhos com peças pequenas.
### 4.3 Aplicação com Rolo e Trincha
Para aplicações de estruturas metálicas maiores onde não se disponha de equipamento pneumático, a **aplicação com rolo** é alternativa viável. O tipo de rolo afeta o resultado: rolos com pelo curto (5-10mm) são preferidos para superfícies lisas, enquanto pelo mais longo (10-15mm) é necessário para superfícies com maior rugosidade. Cerdas naturais ou sintéticas apresentam desempenho similar em tintas industriais.
A **trincha** (pincel) é adequada apenas para retoques, bordas ou áreas de acesso restrito. Para aplicação de grandes áreas, é ineficiente e resulta em acabamento inadequado com visibilidade de marcas de aplicação.
### 4.4 Espessura de Película Úmida e Cálculo da Espessura Seca
Uma variável praticamente desconhecida de muitos pintores mas absolutamente essencial para o controle de qualidade é a **espessura de película úmida (EPU)**, medida imediatamente após aplicação com um instrumento chamado "pente de campanha" (coating thickness gauge para filmes úmidos). Esta medição parece simples (em micrômetros) mas é essencial para cálculo posterior da espessura seca.
A relação entre espessura úmida e seca depende do **sólidos por volume** e **percentual de diluição**:
`Espessura Seca (μm) = EPU (μm) × [SV (%) × (100 - Diluição %)] / 100`
Por exemplo: se EPU = 576 μm, SV = 63%, e diluição = 10%, então:
`EPS = 576 × [63 × (100-10)] / 100 ≈ 327 μm`
Este cálculo é fundamental para garantir que a especificação de espessura seca será atingida. Se o pintor não medir EPU e não fizer este cálculo, aplicará quantidade inadequada de tinta, frequentemente resultando em espessura insuficiente.
### 4.5 Espessura de Película Seca: Mínimo, Máximo e Conformidade
A **espessura de película seca (EPS)** é especificada em cada esquema de pintura, tipicamente em micrômetros (μm). Cada norma e especificação de projeto estabelece valores mínimos e máximos. Por exemplo, um sistema de pintura típico para estruturas de aço pode especificar:
- Primer epóxi: 60-80 μm
- Tinta de acabamento: 50-70 μm
- Total: 110-150 μm
A razão para o limite máximo (não apenas mínimo) é que espessuras excessivas podem resultar em defeitos como **mud cracking** (trincas no filme seco durante cura), particularmente em tintas com alto teor de zinco. Além disso, espessura excessiva causa maior consumo de tinta e custos desnecessários.
A medição de EPS deve ser realizada com **medidor magnético** (para substratos de aço ferroso) ou **eletrônico** (para substratos não ferrosos ou aplicações sobre pintura existente), seguindo ASTM D7091 e SSPC-PA 2. A norma exige mínimo de 15 pontos de medição por área, com aceitabilidade de valores dentro de 80-120% da especificação mínima/máxima (variação de ±20%).
## 5. Secagem, Cura e Tempo Entre Demãos
O comportamento de uma tinta durante secagem e cura é função complexa de temperatura, umidade, tipo de tinta e formulação.
### 5.1 Tempos de Secagem Específicos por Tipo de Tinta
Diferentes tipos de tinta apresentam velocidades de cura muito diferentes, conforme especificações do fabricante:
- **Tintas Epóxi**: cura lenta mas progressiva. A secagem ao toque (capacidade de ser tocada sem deixar marca) ocorre em 6-20 minutos a 25°C. Repintura (aplicação de próxima demão sem rejeição) é possível entre 8-16 horas. Cura completa e resistência máxima é atingida em 168 horas (7 dias) a 25°C.
- **Tintas Poliuretano (PU)**: curam mais rapidamente. Secagem ao toque em 1-4 horas a 25°C. Repintura possível entre 4-24 horas. Cura completa em 7-10 dias a 25°C.
- **Primers de Silicato Inorgânico de Zinco**: curam muito rapidamente. Secagem ao toque em 20-30 minutos, repintura possível em 1 hora.
Estes valores são todas para **25°C e 50% umidade relativa** - as condições de referência padrão. Em outras condições, os tempos mudam dramaticamente.
### 5.2 Intervalo Entre Demãos
O **intervalo mínimo entre demãos** é essencial para garantir que a camada anterior tenha desenvolvido suficiente resistência antes de receber carga de uma nova demão. Se aplicada muito rapidamente, as duas camadas podem não aderir adequadamente uma à outra, resultando em "delaminação" (separação das camadas).
O **intervalo máximo entre demãos** é igualmente importante mas frequentemente negligenciado. Se demasiado tempo passar (tipicamente 24-48 horas para máximo), a superfície da camada anterior pode desenvolver uma fina camada de oxidação ou contaminação atmosférica que prejudica a aderência interdemãos. Além disso, após determinado período, a superfície da tinta "cura demais" e fica mais difícil para a próxima demão "molhar" (espalhar uniformemente).
### 5.3 Influência da Temperatura na Cura
A temperatura afeta a velocidade de cura em ambos os sentidos. A regra empírica aproximada é que a velocidade de cura **dobra a cada aumento de 10°C**, e reduz à metade a cada diminuição de 10°C.
Exemplos práticos:
- Epóxi a 10°C: tempo de repintura pode estender de 8 horas (25°C) para 16-24 horas
- Epóxi a 35°C: tempo de repintura pode reduzir de 8 horas para 4-5 horas
Temperaturas abaixo de 5°C ou acima de 40°C podem resultar em **cura inadequada** ou **propriedades finais deficientes**.
### 5.4 Flash Rust em Tintas à Base de Água
Um fenômeno específico a tintas à base água é o **flash rust** (corrosão branca ou instantânea), que ocorre quando a água presente na tinta condensa sobre a superfície metálica durante a fase de secagem, resultando em oxidação imediata do ferro. Isto é particularmente problemático em ambientes costeiros ou com alta umidade. A solução é a adição de **aditivos anti-flash-rust** na formulação da tinta, que protegem a superfície durante a secagem.
## 6. Inspeção Final e Controle de Qualidade
A inspeção adequada garante conformidade com especificações e identifica problemas antes que o sistema seja colocado em serviço.
### 6.1 Medição de Espessura de Película Seca
A medição de **espessura de película seca (EPS)** é realizada com medidores de espessura específicos. Para aço ferroso, utilizam-se medidores **magnéticos**, que medem a distância magnética entre o medidor e o substrato metálico. Para substratos não ferrosos (alumínio, cobre) ou quando a pintura é aplicada sobre pintura existente, utilizam-se medidores **eletrônicos** que usam princípio ultrassônico ou de eddy current.
A norma ASTM D7091 e SSPC-PA 2 especificam que **pelo menos 15 medições** devem ser realizadas por área de aproximadamente 5m², distribuídas uniformemente para capturar variações locais. O resultado deve estar entre 80% e 120% das espessuras mínima e máxima especificadas.
### 6.2 Testes de Aderência
A **aderência** é validada através de múltiplos testes normalizados:
- **Teste de Tração (Pull-Off Test)** conforme ASTM D4541 e ISO 16276-1: um cilindro de teste (dolly) é colado à superfície pintada, e uma máquina de tração puxa o cilindro perpendicularmente até que a tinta falhe (ou o cilindro se separe). A força necessária é registrada em MPa.
- **Teste de Aderência por Incisão (Corte em X)** conforme ASTM D3359 e ABNT NBR 11003: uma faca especial faz dois cortes perpendiculares na tinta, criando um padrão em X. O resultado é avaliado visualmente quanto ao grau de desprendimento: 5B (sem desprendimento) é ideal, enquanto 0B (desprendimento completo) indica falha total.
### 6.3 Identificação de Defeitos
Durante inspeção visual, múltiplos tipos de defeitos podem ser identificados, conforme normas como ISO 4628:
- **Bolhas (blistering)**: ar ou solventes aprisionados criam bolhas no filme. Indicam preparação inadequada, umidade durante aplicação ou cura inadequada.
- **Buracos e Crateras (pinholes, cratering)**: pequenos furos no filme seco. Causados por escape de ar ou solvente durante cura, ou contaminação da superfície.
- **Descamação (peeling)**: tinta se separa completamente do substrato. Indicador de aderência inicial inadequada, frequentemente resultante de preparação de superfície insuficiente.
- **Empolamento Osmótico (osmotic blistering)**: bolhas maiores em padrão não aleatório. Indicador específico de sais solúveis contaminantes que atraem umidade.
- **Mudança de cor ou perda de brilho**: indicador de exposição UV excessiva ou cura incompleta. Critério de aceitabilidade depende de especificação.
## 7. Normas Técnicas Brasileiras e Internacionais Aplicáveis
O processo de pintura industrial de estruturas metálicas é governado por um conjunto abrangente de normas brasileiras ABNT, normas internacionais ISO, SSPC e NACE, além das normas técnicas específicas Petrobras.
### 7.1 Normas ABNT Relevantes
- **ABNT NBR 8800:2008** - Projeto de estruturas de aço e de estruturas mistas de aço e concreto de edifícios: estabelece requisitos para projeto de estruturas, incluindo proteção contra corrosão e especificações de pintura.
- **ABNT NBR 10443** - Pintura industrial — Determinação da espessura da película seca sobre superfícies metálicas ferrosas e não ferrosas: descreve métodos de medição de espessura seca.
- **ABNT NBR 11003** - Pintura industrial — Determinação da aderência pelos métodos de corte na pintura: especifica teste de aderência por incisão.
- **ABNT NBR 14847:2023** - Inspeção de serviços de pintura em superfícies metálicas - Procedimento: estabelece procedimentos completos de inspeção.
- **ABNT NBR 15158** - Pintura industrial — Limpeza de superfícies de aço por produtos químicos: define procedimentos para limpeza química.
- **ABNT NBR 15239** - Pintura industrial — Tratamento de superfícies de aço carbono com ferramentas manuais e mecânicas.
- **ABNT NBR 15877** - Pintura industrial — Determinação da resistência à tração em sistemas de pintura e outros revestimentos anticorrosivos.
- **ABNT NBR 15218** - Critérios para qualificação e certificação de inspetores de pintura industrial.
- **ABNT NBR 16267** - Pintura industrial — Determinação de granulometria de abrasivos para jateamento.
- **ABNT NBR 5829** - Determinação de sólidos em massa em tintas.
- **ABNT NBR 7348** - Pintura industrial - Preparação de superfície de aço-carbono com jateamento abrasivo seco, úmido ou hidrojateamento à ultra-alta pressão.
### 7.2 Normas ISO Internacionais
A **série ISO 12944** é a norma internacional de referência mais importante para pintura industrial anticorrosiva de estruturas de aço:
- ISO 12944-1: Generalidades
- ISO 12944-3: Critérios de projeto estrutural
- ISO 12944-4: Métodos de preparação de superfície
- ISO 12944-5: Sistemas de pintura protetora
- ISO 12944-6: Testes laboratoriais de desempenho
- ISO 12944-8: Desenvolvimento de especificações de pintura
- ISO 12944-9: Sistemas para estruturas offshore e ambientes agressivos
Outras normas ISO relevantes incluem:
- **ISO 8501-1, 8501-2, 8501-3**: padrões visuais e fotográficos de limpeza de superfícies.
- **ISO 8502-3, 8502-4, 8502-6, 8502-9**: métodos para avaliação de contaminação superficial.
- **ISO 8503-1, 8503-2**: métodos para avaliação de rugosidade de superfície.
- **ISO 3251**: determinação do teor de matéria não volátil (sólidos).
- **ISO 2431**: determinação de viscosidade pelo método de copo de fluxo.
### 7.3 Normas SSPC e NACE
As normas **SSPC** e **NACE** são amplamente adotadas internacionalmente:
- **SSPC-SP**: SP 6, SP 7, SP 10, SP 11, SP 13, SP 14 (graus de limpeza e preparação de superfície).
- **SSPC-Guide 12**: orientações para iluminação em projetos de pintura industrial.
- **SSPC-PA 2**: procedimento para medição de espessura de película seca.
- **SSPC-VIS / NACE VIS**: padrões fotográficos para avaliação de limpeza de superfície.
### 7.4 Normas Petrobras Técnicas
As normas Petrobras são específicas para aplicações em indústria de petróleo e gás, mas são frequentemente adotadas como referência em outras indústrias:
- **PETROBRAS N-13**: Requisitos técnicos para serviços de pintura.
- **PETROBRAS N-1550**: Procedimento para seleção de esquemas de pintura de estruturas metálicas em instalações terrestres.
- **PETROBRAS N-1661**: Tinta de etil silicato de zinco para proteção anticorrosiva.
- **PETROBRAS N-1205**: Revestimento anticorrosivo interno de tubulações industriais.
- **PETROBRAS N-2288**: Tinta epóxi pigmentada com alumínio.
- **PETROBRAS N-2680**: Tinta epóxi sem solventes, tolerante a superfícies molhadas.
- **PETROBRAS N-2912**: Tinta epóxi "Novolac".
- **PETROBRAS N-2913**: Revestimentos anticorrosivos para tanque, esfera e cilindro de armazenamento.
- **PETROBRAS N-9**: Tratamento de superfícies de aço com jato abrasivo.
## 8. Conclusão
O processo de pintura industrial de estruturas metálicas é um exercício complexo de controle de múltiplas variáveis interdependentes, onde falhas em qualquer etapa comprometem o resultado final. Desde a inspeção inicial da superfície, passando pela seleção e preparação de tintas, condições ambientais durante aplicação, até os testes finais de aderência e espessura, cada variável exerce influência significativa na durabilidade e eficiência econômica do revestimento.
As normas brasileiras ABNT, em conjunto com normas internacionais ISO, SSPC, NACE e normas Petrobras, estabelecem requisitos técnicos rigorosos que, quando apropriadamente seguidos, garantem sistemas de pintura que protegem estruturas de aço contra corrosão durante muitos anos. O investimento em controle adequado de preparação de superfície, seguido de aplicação técnica rigorosa e inspeção sistemática, resulta em redução significativa de custos de manutenção futura e prolongamento da vida útil dos ativos.
Para profissionais envolvidos em pintura de estruturas metálicas — engenheiros, técnicos, inspetores ou aplicadores — o entendimento profundo das variáveis documentadas neste relatório é essencial para garantir conformidade com especificações, identificar problemas antes que se tornem críticos, e evitar falhas prematuras de revestimento que comprometem a integridade estrutural e aumentam custos operacionais.

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

BIN
public/maskable-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
public/steelpaint_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
public/steelpaint_iconw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
public/steelpaint_iconw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

48
refactor_clerk.cjs Normal file
View File

@@ -0,0 +1,48 @@
const fs = require('fs');
const path = require('path');
const rootDir = 'C:\\Users\\Marcos\\.gemini\\antigravity\\scratch\\gpi\\src';
const replacements = [
{ from: /clerkId/g, to: 'externalId' },
{ from: /clerkUserId/g, to: 'userId' },
{ from: /x-clerk-user-id/g, to: 'x-auth-user-id' },
{ from: /Clerk/g, to: 'Auth' }, // Use with caution, but mostly it's ClerkProvider or Clerk-related
// Add more if needed
];
function walk(dir) {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
walk(fullPath);
} else if (file.endsWith('.ts') || file.endsWith('.tsx') || file.endsWith('.js') || file.endsWith('.html')) {
processFile(fullPath);
}
}
}
function processFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
let modified = false;
for (const r of replacements) {
if (r.from.test(content)) {
content = content.replace(r.from, r.to);
modified = true;
}
}
if (modified) {
console.log(`Updated: ${filePath}`);
fs.writeFileSync(filePath, content, 'utf8');
}
}
console.log('Starting global refactor...');
walk(rootDir);
// Also check index.html
processFile('C:\\Users\\Marcos\\.gemini\\antigravity\\scratch\\gpi\\index.html');
console.log('Refactor complete.');

592
relatorio.html Normal file
View File

@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Relatório de Obras / Projetos Pintura</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
--bg: #0f1115;
--bg-elevated: #181b21;
--accent: #f97316;
--accent-soft: rgba(249, 115, 22, 0.12);
--text-main: #f9fafb;
--text-muted: #9ca3af;
--border-subtle: #272b35;
--success: #22c55e;
--danger: #ef4444;
--chip-bg: #1f2937;
--chip-border: #374151;
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background: radial-gradient(circle at top left, #1f2933 0, #050608 45%);
color: var(--text-main);
padding: 32px;
}
.page {
max-width: 1200px;
margin: 0 auto;
background: linear-gradient(145deg, #0b0d11, #111827);
border-radius: 18px;
box-shadow: 0 24px 80px rgba(0,0,0,0.75);
padding: 28px 32px 32px;
border: 1px solid rgba(148, 163, 184, 0.18);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 12px;
background: radial-gradient(circle at 20% 0%, #fbbf24, #f97316);
box-shadow: 0 0 0 1px rgba(15,23,42,0.8), 0 14px 30px rgba(249,115,22,0.5);
}
.brand-text h1 {
font-size: 18px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #e5e7eb;
}
.brand-text p {
font-size: 13px;
color: var(--text-muted);
}
.report-meta {
text-align: right;
font-size: 12px;
color: var(--text-muted);
}
.report-meta strong {
color: var(--text-main);
font-weight: 600;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(15,23,42,0.9);
border: 1px solid rgba(148,163,184,0.35);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--text-muted);
margin-top: 6px;
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: radial-gradient(circle at 30% 0, #4ade80, #16a34a);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.summary-card {
background: radial-gradient(circle at top left, #111827, #020617);
border-radius: 14px;
border: 1px solid rgba(31, 41, 55, 0.95);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--text-muted);
}
.summary-value {
font-size: 20px;
font-weight: 600;
}
.summary-sub {
font-size: 11px;
color: var(--text-muted);
}
.summary-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(34, 197, 94, 0.65);
background: rgba(22, 163, 74, 0.12);
font-size: 11px;
color: #bbf7d0;
}
/* tabela/lista de obras em cards */
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0 10px;
}
.section-title h2 {
font-size: 14px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-muted);
}
.section-title span {
font-size: 11px;
color: var(--text-muted);
}
.projects {
display: flex;
flex-direction: column;
gap: 10px;
}
.project-card {
display: grid;
grid-template-columns: 2.1fr 0.8fr 1.1fr 0.8fr 1.3fr 0.7fr;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: linear-gradient(135deg, #0b0f1a, #020617);
border: 1px solid var(--border-subtle);
position: relative;
overflow: hidden;
}
.project-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at top left, rgba(249, 115, 22, 0.06), transparent 55%);
opacity: 0.9;
pointer-events: none;
}
.project-main {
position: relative;
z-index: 1;
}
.project-code {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--text-muted);
margin-bottom: 2px;
}
.project-name {
font-size: 14px;
font-weight: 600;
}
.project-client {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.progress-pill {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 8px;
}
.progress-track {
width: 80px;
height: 6px;
background: #020617;
border-radius: 999px;
overflow: hidden;
border: 1px solid #111827;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #fbbf24, #f97316);
width: var(--progress, 0%);
}
.progress-label {
font-size: 12px;
color: var(--text-muted);
min-width: 30px;
text-align: right;
}
.schedule {
position: relative;
z-index: 1;
font-size: 11px;
color: var(--text-muted);
line-height: 1.4;
}
.schedule strong {
color: var(--text-main);
font-weight: 500;
}
.weight {
position: relative;
z-index: 1;
font-size: 13px;
font-weight: 600;
}
.weight span {
display: block;
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.14em;
margin-top: 2px;
}
.paint {
position: relative;
z-index: 1;
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
}
.paint strong {
display: block;
color: var(--text-main);
font-weight: 500;
font-size: 11px;
}
.color-chip {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 11px;
}
.color-dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid #020617;
box-shadow: 0 0 0 1px rgba(148,163,184,0.4);
}
.color-name {
color: var(--text-muted);
}
.color-dot.gray-n65 {
background: radial-gradient(circle at 20% 0, #f9fafb, #4b5563);
}
.color-dot.white {
background: radial-gradient(circle at 20% 0, #f9fafb, #e5e7eb);
}
.color-dot.gray {
background: radial-gradient(circle at 20% 0, #e5e7eb, #4b5563);
}
footer {
margin-top: 24px;
padding-top: 12px;
border-top: 1px dashed rgba(55, 65, 81, 0.7);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10px;
color: var(--text-muted);
}
.sig-line {
margin-top: 4px;
}
.sig-line span {
display: inline-block;
min-width: 180px;
border-bottom: 1px solid rgba(75,85,99,0.8);
margin-left: 6px;
height: 14px;
}
/* responsividade básica para visualização em tela */
@media (max-width: 900px) {
.page {
padding: 20px;
}
header {
flex-direction: column;
align-items: flex-start;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.project-card {
grid-template-columns: 1.7fr 1fr;
grid-template-rows: auto auto auto;
grid-template-areas:
"main main"
"progress schedule"
"weight paint";
}
.project-main { grid-area: main; }
.progress-pill { grid-area: progress; justify-self: flex-start; }
.schedule { grid-area: schedule; }
.weight { grid-area: weight; }
.paint { grid-area: paint; }
.color-chip { display: none; }
}
@media print {
body {
background: #ffffff;
padding: 0;
}
.page {
border-radius: 0;
box-shadow: none;
border: none;
max-width: 100%;
}
}
</style>
</head>
<body>
<div class="page">
<header>
<div class="brand">
<div class="brand-mark"></div>
<div class="brand-text">
<h1>RELATÓRIO DE PINTURA</h1>
<p>Obras / Projetos Situação de produção e pintura</p>
</div>
</div>
<div class="report-meta">
<div><strong>Data:</strong> ____/____/________</div>
<div><strong>Responsável:</strong> __________________________</div>
<span class="pill">
<span class="pill-dot"></span>
RELATÓRIO RESUMIDO
</span>
</div>
</header>
<section class="summary-grid">
<article class="summary-card">
<div class="summary-label">Total de obras</div>
<div class="summary-value">3</div>
<div class="summary-sub">Listadas neste relatório</div>
</article>
<article class="summary-card">
<div class="summary-label">Peso total (kgf)</div>
<div class="summary-value">51.845</div>
<div class="summary-sub">Soma dos projetos</div>
</article>
<article class="summary-card">
<div class="summary-label">Evolução média</div>
<div class="summary-value">3,3%</div>
<span class="summary-badge">
▲ andamento inicial
</span>
</article>
<article class="summary-card">
<div class="summary-label">Período</div>
<div class="summary-value">11/2025 03/2026</div>
<div class="summary-sub">Previsão de execução</div>
</article>
</section>
<div class="section-title">
<h2>OBRAS / PROJETOS</h2>
<span>Visão geral por código, cronograma, peso e sistema de pintura</span>
</div>
<section class="projects">
<!-- B121 -->
<article class="project-card" style="--progress:10%;">
<div class="project-main">
<div class="project-code">B121 · RESIDÊNCIA BIA</div>
<div class="project-name">Residência Bia</div>
<div class="project-client">Cliente: FAIRBANKS</div>
</div>
<div class="progress-pill">
<div class="progress-track">
<div class="progress-fill"></div>
</div>
<div class="progress-label">10%</div>
</div>
<div class="schedule">
<div><strong>Início</strong> 30/11/2025</div>
<div><strong>Término</strong> 30/03/2026</div>
</div>
<div class="weight">
32.165
<span>peso total (kgf)</span>
</div>
<div class="paint">
<strong>REVRAN DST QD 721</strong>
Primer / Sistema alquídico (exemplo)
</div>
<div class="color-chip">
<div class="color-dot gray-n65"></div>
<div class="color-name">CINZA N6,5</div>
</div>
</article>
<!-- B128 -->
<article class="project-card" style="--progress:0%;">
<div class="project-main">
<div class="project-code">B128 · COBERTURA BRIDGESTONE</div>
<div class="project-name">Cobertura Bridgestone</div>
<div class="project-client">Cliente: BRIDGESTONE</div>
</div>
<div class="progress-pill">
<div class="progress-track">
<div class="progress-fill"></div>
</div>
<div class="progress-label">0%</div>
</div>
<div class="schedule">
<div><strong>Início</strong> 14/01/2026</div>
<div><strong>Término</strong> 30/03/2026</div>
</div>
<div class="weight">
6.880
<span>peso total (kgf)</span>
</div>
<div class="paint">
<strong>REVRAN PHZ 528</strong>
Primer<br />
<strong>OXIBAR DFC 707</strong>
Acabamento
</div>
<div class="color-chip">
<div class="color-dot gray-n65"></div>
<div class="color-name">CINZA N6,5</div>
<div class="color-dot white"></div>
<div class="color-name">BRANCO</div>
</div>
</article>
<!-- B129 -->
<article class="project-card" style="--progress:0%;">
<div class="project-main">
<div class="project-code">B129 · RAMPA TOYOTA</div>
<div class="project-name">Rampa Toyota</div>
<div class="project-client">Cliente: LOJA TOYOTA</div>
</div>
<div class="progress-pill">
<div class="progress-track">
<div class="progress-fill"></div>
</div>
<div class="progress-label">0%</div>
</div>
<div class="schedule">
<div><strong>Início</strong> 10/01/2026</div>
<div><strong>Término</strong> 30/03/2026</div>
</div>
<div class="weight">
12.800
<span>peso total (kgf)</span>
</div>
<div class="paint">
<strong>REVRAN PHZ 528</strong>
Primer
</div>
<div class="color-chip">
<div class="color-dot gray"></div>
<div class="color-name">CINZA (granulação)</div>
</div>
</article>
</section>
<footer>
<div>
<div>Gerado em ____/____/________ às ______h</div>
<div>ID do relatório: _____________________</div>
</div>
<div>
<div class="sig-line">Responsável Técnico<span></span></div>
<div class="sig-line">Responsável Qualidade<span></span></div>
</div>
</footer>
</div>
</body>
</html>

501
relatorio2.html Normal file
View File

@@ -0,0 +1,501 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Relatório de Obras / Projetos Pintura</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
/* Página A4 e estilos de impressão */
@page {
size: A4;
margin: 10mm;
}
html, body {
padding: 0;
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 11pt;
color: #000;
background: #f5f5f5;
}
body {
display: flex;
justify-content: center;
align-items: flex-start;
}
.page {
width: 190mm; /* 210mm - 2x10mm da margem @page */
min-height: 277mm; /* 297mm - 2x10mm */
background: #fff;
padding: 12mm;
box-sizing: border-box;
box-shadow: 0 0 6mm rgba(0,0,0,0.12);
}
@media print {
html, body {
background: #fff;
}
.page {
margin: 0;
box-shadow: none;
width: 100%;
min-height: auto;
}
}
/* Estilos gerais do relatório (todos em cinza/preto) */
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #999;
padding-bottom: 6mm;
margin-bottom: 6mm;
}
.brand {
max-width: 60%;
}
.brand-title {
font-size: 13pt;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.brand-subtitle {
font-size: 9pt;
color: #555;
margin-top: 1mm;
}
.brand-mark {
width: 10mm;
height: 10mm;
border-radius: 2mm;
border: 1px solid #000;
display: inline-block;
margin-right: 3mm;
}
.meta {
text-align: right;
font-size: 9pt;
color: #333;
}
.meta strong {
font-weight: 600;
}
.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;
}
/* Resumo superior */
.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;
}
/* Título da seção de obras */
.section-title {
display: flex;
justify-content: space-between;
align-items: baseline;
margin: 2mm 0 2mm;
border-top: 1px solid #ccc;
padding-top: 2mm;
}
.section-title h2 {
font-size: 9pt;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #333;
}
.section-title span {
font-size: 8pt;
color: #666;
}
/* Lista de projetos em “linhas” */
.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 tbody tr:last-child {
border-bottom: 1pt solid #000;
}
.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: 20mm; text-align: right; }
.col-tinta { width: 40mm; }
.col-cor { width: 20mm; }
.obra-codigo {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #555;
}
.obra-nome {
font-weight: 600;
font-size: 9pt;
}
.obra-cliente {
font-size: 8pt;
color: #555;
}
.evol {
font-size: 9pt;
font-weight: 600;
}
.evol-bar {
margin-top: 1mm;
height: 3mm;
border-radius: 999px;
background: #eee;
overflow: hidden;
}
.evol-fill {
height: 100%;
background: #000;
}
.cron {
font-size: 8pt;
line-height: 1.4;
}
.cron strong {
font-weight: 600;
}
.peso {
font-size: 9pt;
font-weight: 600;
}
.peso span {
display: block;
font-size: 7pt;
color: #666;
}
.tinta {
font-size: 8pt;
line-height: 1.3;
}
.tinta strong {
display: block;
font-weight: 600;
}
.cor {
font-size: 8pt;
line-height: 1.3;
}
/* Rodapé */
footer {
margin-top: 8mm;
border-top: 1px solid #999;
padding-top: 3mm;
display: flex;
justify-content: space-between;
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;
}
</style>
</head>
<body>
<div class="page">
<header>
<div class="brand">
<div>
<span class="brand-mark"></span>
<span class="brand-title">RELATÓRIO DE PINTURA</span>
</div>
<div class="brand-subtitle">
Obras / Projetos Situação de produção e pintura
</div>
</div>
<div class="meta">
<div><strong>Data:</strong> ____/____/________</div>
<div><strong>Responsável:</strong> __________________________</div>
<div class="tag">Relatório resumido</div>
</div>
</header>
<section class="summary">
<div class="summary-item">
<div class="summary-label">Total de obras</div>
<div class="summary-value">3</div>
<div class="summary-sub">Listadas neste relatório</div>
</div>
<div class="summary-item">
<div class="summary-label">Peso total (kgf)</div>
<div class="summary-value">51.845</div>
<div class="summary-sub">Soma dos projetos</div>
</div>
<div class="summary-item">
<div class="summary-label">Evolução média</div>
<div class="summary-value">3,3%</div>
<div class="summary-sub">Estimativa geral</div>
</div>
<div class="summary-item">
<div class="summary-label">Período</div>
<div class="summary-value">11/2025 03/2026</div>
<div class="summary-sub">Previsão de execução</div>
</div>
</section>
<div class="section-title">
<h2>OBRAS / PROJETOS</h2>
<span>Visão geral por código, cronograma, peso e sistema de pintura</span>
</div>
<table class="table">
<thead>
<tr>
<th class="col-cod">Cód.</th>
<th class="col-obra">Obra / Projeto</th>
<th class="col-evol">Evol.</th>
<th class="col-cron">Cronograma</th>
<th class="col-peso">Peso (kgf)</th>
<th class="col-tinta">Tinta</th>
<th class="col-cor">Cor</th>
</tr>
</thead>
<tbody>
<!-- B121 -->
<tr>
<td class="col-cod">
<div class="obra-codigo">B121</div>
</td>
<td class="col-obra">
<div class="obra-nome">RESIDÊNCIA BIA</div>
<div class="obra-cliente">Cliente: FAIRBANKS</div>
</td>
<td class="col-evol">
<div class="evol">10%</div>
<div class="evol-bar">
<div class="evol-fill" style="width:10%;"></div>
</div>
</td>
<td class="col-cron">
<div class="cron">
<strong>Início:</strong> 30/11/2025<br>
<strong>Término:</strong> 30/03/2026
</div>
</td>
<td class="col-peso">
<div class="peso">
32.165
<span>Est. total</span>
</div>
</td>
<td class="col-tinta">
<div class="tinta">
<strong>REVRAN DST QD 721</strong>
Primer
</div>
</td>
<td class="col-cor">
<div class="cor">
CINZA N6,5
</div>
</td>
</tr>
<!-- B128 -->
<tr>
<td class="col-cod">
<div class="obra-codigo">B128</div>
</td>
<td class="col-obra">
<div class="obra-nome">COBERTURA BRIDGESTONE</div>
<div class="obra-cliente">Cliente: BRIDGESTONE</div>
</td>
<td class="col-evol">
<div class="evol">0%</div>
<div class="evol-bar">
<div class="evol-fill" style="width:0%;"></div>
</div>
</td>
<td class="col-cron">
<div class="cron">
<strong>Início:</strong> 14/01/2026<br>
<strong>Término:</strong> 30/03/2026
</div>
</td>
<td class="col-peso">
<div class="peso">
6.880
<span>Est. total</span>
</div>
</td>
<td class="col-tinta">
<div class="tinta">
<strong>REVRAN PHZ 528</strong>
Primer<br>
<strong>OXIBAR DFC 707</strong>
Acabamento
</div>
</td>
<td class="col-cor">
<div class="cor">
CINZA N6,5<br>
BRANCO
</div>
</td>
</tr>
<!-- B129 -->
<tr>
<td class="col-cod">
<div class="obra-codigo">B129</div>
</td>
<td class="col-obra">
<div class="obra-nome">RAMPA TOYOTA</div>
<div class="obra-cliente">Cliente: LOJA TOYOTA</div>
</td>
<td class="col-evol">
<div class="evol">0%</div>
<div class="evol-bar">
<div class="evol-fill" style="width:0%;"></div>
</div>
</td>
<td class="col-cron">
<div class="cron">
<strong>Início:</strong> 10/01/2026<br>
<strong>Término:</strong> 30/03/2026
</div>
</td>
<td class="col-peso">
<div class="peso">
12.800
<span>Est. total</span>
</div>
</td>
<td class="col-tinta">
<div class="tinta">
<strong>REVRAN PHZ 528</strong>
Primer
</div>
</td>
<td class="col-cor">
<div class="cor">
CINZA (granulação)
</div>
</td>
</tr>
</tbody>
</table>
<footer>
<div>
Gerado em ____/____/________ às ______h<br>
ID do relatório: ______________________
</div>
<div class="sig-group">
<div class="sig-line">Responsável Técnico<span></span></div>
<div class="sig-line">Responsável Qualidade<span></span></div>
</div>
</footer>
</div>
</body>
</html>

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

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

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

@@ -0,0 +1,129 @@
// App v1.5 - JWT Migration Final Check
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
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 { appUser, isLoading } = useAuth();
if (isLoading) return <div className="flex h-screen items-center justify-center">Carregando...</div>;
// AppUser exists but hasn't selected an org yet (if your business logic requires orgs)
if (appUser && !appUser.organizationId) {
return <OrganizationSelector />;
}
return (
<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>
);
};
const MainRouter: React.FC = () => {
const { appUser, isLoading } = useAuth();
if (isLoading) {
return <div className="flex h-screen items-center justify-center">Verificando sessão...</div>;
}
return (
<Router>
{!appUser ? (
<Login />
) : (
<AppContent />
)}
</Router>
);
};
function App() {
return (
<ToastProvider>
<AuthProvider>
<SystemSettingsProvider>
<NotificationProvider>
<MainRouter />
</NotificationProvider>
</SystemSettingsProvider>
</AuthProvider>
</ToastProvider>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,375 @@
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 { useAuth } from '../context/useAuth';
import { TechnicalManual } from './TechnicalManual';
// import { useSystemSettings } from '../context/SystemSettingsContext';
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 { isAdmin, isUser, isDeveloper, appUser, logout } = useAuth();
// const { settings } = useSystemSettings();
// 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">
<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="Organização">
<div className="w-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs">
ORG
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">Organização Matriz</p>
<p className="text-[10px] text-text-muted uppercase tracking-wider">Conta</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={() => logout()}
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>
<div className="w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center font-bold text-xs">
{appUser?.name?.substring(0, 2).toUpperCase() || 'US'}
</div>
<div className="flex flex-col">
<span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{appUser?.name || 'Usuário'}</span>
<span className="text-[8px] text-text-muted">v2.1.0</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className={`w-1.5 h-1.5 rounded-full ${appUser?.role === 'admin' ? 'bg-amber-500' : appUser?.role === 'user' ? 'bg-green-500' : 'bg-blue-400'}`}></span>
<span className={`text-[10px] font-medium ${roleInfo.color}`}>{roleInfo.label}</span>
</div>
</div>
</div>
</aside>
{/* Mobile Header */}
<header className="md:hidden fixed top-0 left-0 right-0 h-16 bg-surface/80 backdrop-blur-xl border-b border-border/40 z-30 flex items-center justify-between px-6">
<div className="flex items-center gap-3">
<img
src={isDarkMode ? "/steelpaint_icon.png" : "/steelpaint_iconw.png"}
alt="Logo"
className="w-8 h-8 object-contain"
/>
<span className="font-bold text-lg text-text-main">SteelPaint</span>
</div>
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 text-text-main hover:bg-surface-hover rounded-lg transition-colors"
>
{isSidebarOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</header>
{/* Mobile Sidebar Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/60 z-40 md:hidden backdrop-blur-sm transition-all animate-in fade-in"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Mobile Sidebar Drawer */}
<div className={clsx(
"fixed inset-y-0 left-0 w-72 bg-surface z-50 transform transition-transform duration-300 ease-out md:hidden flex flex-col border-r border-border/40 shadow-2xl",
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
)}>
<div className="p-8 border-b border-border/40 flex items-center justify-between">
<div className="flex items-center gap-3">
<img
src={isDarkMode ? "/steelpaint_icon.png" : "/steelpaint_iconw.png"}
alt="Logo"
className="w-8 h-8 object-contain"
/>
<span className="font-bold text-lg text-text-main">SteelPaint</span>
</div>
<button onClick={() => setIsSidebarOpen(false)} className="p-2 text-text-muted hover:text-text-main" aria-label="Close Menu">
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto py-8 px-6 space-y-2">
{navItems.map((item) => {
if (item.adminOnly && !isAdmin()) return null;
return (
<Link
key={item.path}
to={item.path}
onClick={() => setIsSidebarOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-2xl text-base font-bold transition-all",
isActive(item.path)
? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-text-secondary hover:bg-surface-hover"
)}
>
<item.icon size={22} />
{item.label}
</Link>
)
})}
{/* Admin Menu Item - Mobile */}
{isAdmin() && (
<Link
to="/admin"
onClick={() => setIsSidebarOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-2xl text-base font-bold transition-all mt-4",
isActive('/admin')
? "bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/20"
: "text-text-secondary hover:bg-surface-hover"
)}
>
<Shield size={22} className="text-amber-500" />
Administração
</Link>
)}
{/* Developer Menu Item - Mobile */}
{(isDeveloper() || isAdmin() || isUser()) && (
<Link
to="/developer"
onClick={() => setIsSidebarOpen(false)}
className={clsx(
"flex items-center gap-4 px-4 py-4 rounded-2xl text-base font-bold transition-all mt-4 font-mono",
isActive('/developer')
? "bg-gradient-to-r from-indigo-500 to-purple-500 text-white shadow-lg shadow-indigo-500/20"
: "text-text-secondary hover:bg-surface-hover hover:text-indigo-400"
)}
>
<Terminal size={22} className="text-indigo-500" />
Área Dev
</Link>
)}
</nav>
<div className="p-8 border-t border-border/40 space-y-4">
<button onClick={toggleTheme} className="flex items-center gap-4 w-full text-text-main font-bold">
{isDarkMode ? <Sun size={20} className="text-yellow-500" /> : <Moon size={20} className="text-primary" />}
{isDarkMode ? 'Modo Claro' : 'Modo Escuro'}
</button>
</div>
</div>
{/* Main Content Area */}
<main className={clsx(
"flex-1 min-h-screen transition-all duration-300",
"md:pl-68", // Push content on desktop
"pt-16 md:pt-0" // Add padding on mobile for header
)}>
<div className="max-w-7xl mx-auto px-6 sm:px-10 lg:px-12 py-10 w-full animate-in fade-in slide-in-from-bottom-4 duration-500">
{children}
</div>
</main>
{/* Floating Help Button */}
<button
onClick={() => setIsManualOpen(true)}
className="fixed bottom-4 right-4 md:top-6 md:right-6 z-30 w-12 h-12 bg-primary hover:bg-primary-dark text-white rounded-xl shadow-lg shadow-primary/30 flex items-center justify-center transition-all hover:scale-105 group"
title="Manual Técnico"
aria-label="Abrir Manual Técnico"
>
<HelpCircle size={22} className="group-hover:scale-110 transition-transform" />
</button>
{/* Technical Manual Modal */}
<TechnicalManual isOpen={isManualOpen} onClose={() => setIsManualOpen(false)} />
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,184 @@
import React from 'react';
import { usePresence } from '../hooks/usePresence';
import { useAuth } from '../context/useAuth';
import { SendMessageModal } from './SendMessageModal';
import api from '../services/api';
interface OrganizationMember {
_id: string;
name: string;
email: string;
userId: 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 [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);
// Fetch all members
const fetchMembers = React.useCallback(async () => {
try {
const response = await api.get<OrganizationMember[]>('/users');
setAllMembers(response.data);
} catch (error) {
console.error('Error fetching members:', error);
}
}, []);
// Fetch pending messages
const fetchPendingMessages = React.useCallback(async () => {
try {
const response = await api.get<PendingMessage[]>('/messages/pending');
setPendingMessages(response.data);
} catch (error) {
console.error('Error fetching pending messages:', error);
}
}, []);
React.useEffect(() => {
if (appUser) {
fetchMembers();
fetchPendingMessages();
const memberInterval = setInterval(fetchMembers, 60000);
const messageInterval = setInterval(fetchPendingMessages, 30000);
return () => {
clearInterval(memberInterval);
clearInterval(messageInterval);
};
}
}, [appUser, fetchMembers, fetchPendingMessages]);
if (allMembers.length === 0) {
return null;
}
// Create a Set of active user IDs for fast lookup
const activeUserEmails = new Set(activeUsers.map(u => u.email));
// 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.email === appUser?.email) {
return; // Don't allow messaging yourself
}
setSelectedUser({ id: member.email, name: member.name });
setIsModalOpen(true);
};
const handleModalClose = () => {
setIsModalOpen(false);
setSelectedUser(null);
};
const handleMessageSent = async () => {
fetchPendingMessages();
};
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 = activeUserEmails.has(member.email);
const isCurrentUser = member.email === appUser?.email;
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.email === selectedUser.id)!)}
onMessageSent={handleMessageSent}
/>
)}
</>
);
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,322 @@
import React, { useState, useRef } from 'react';
import { Download, Upload, AlertTriangle, CheckCircle, Database, FileJson, Info, RefreshCw } from 'lucide-react';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
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 { appUser } = useAuth();
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 (!appUser) 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_gpi_${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 do sistema ou restaurar dados de um backup anterior.
<strong className="text-amber-500"> Os backups são específicos para cada instalação e podem não ser compatíveis entre versões diferentes se houver mudanças estruturais.</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
? '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 ? (
<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
? '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 || isImporting}
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-red-500/20"
>
{isImporting ? (
<>
<RefreshCw size={20} className="animate-spin" />
Restaurando...
</>
) : (
<>
<AlertTriangle size={20} />
Restaurar Backup
</>
)}
</button>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-xl">
<p className="text-xs text-red-400 flex items-start gap-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
<span>
<strong>ATENÇÃO:</strong> Restaurar um backup irá SUBSTITUIR PERMANENTEMENTE todos os dados atuais.
Esta ação não pode ser desfeita!
</span>
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,127 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { AppUser } from '../types';
import { setApiToken, setApiOrganizationId, getBaseUrl } from '../services/api';
const API_URL = getBaseUrl();
export interface AuthContextType {
appUser: AppUser | null;
isLoading: boolean;
isSignedIn: boolean;
error: string | null;
token: string | null;
login: (token: string, user: AppUser) => void;
logout: () => void;
isAdmin: () => boolean;
isUser: () => boolean;
isGuest: () => boolean;
isDeveloper: () => boolean;
canEdit: () => boolean;
refetchUser: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [appUser, setAppUser] = useState<AppUser | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('jwt_token'));
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Initial load: se tem token, setar no interceptor e buscar dados do usuário
useEffect(() => {
if (token) {
setApiToken(token);
refetchUser();
} else {
setIsLoading(false);
}
}, [token]);
const login = useCallback((newToken: string, user: AppUser) => {
localStorage.setItem('jwt_token', newToken);
setToken(newToken);
setAppUser(user);
setApiToken(newToken);
// Se a organização existir, setar o header
if (user.organizationId) {
setApiOrganizationId(user.organizationId);
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem('jwt_token');
setToken(null);
setAppUser(null);
setApiToken(null);
setApiOrganizationId(null);
}, []);
const refetchUser = useCallback(async () => {
if (!token) return;
setIsLoading(true);
try {
const response = await fetch(`${API_URL}/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`
},
});
if (response.ok) {
const userData = await response.json();
setAppUser(userData);
if (userData.organizationId) {
setApiOrganizationId(userData.organizationId);
}
} else {
// Token inválido ou expirado
logout();
}
} catch (err) {
console.error('Error refetching user:', err);
setError('Falha na comunicação de autenticação.');
} finally {
setIsLoading(false);
}
}, [token, logout]);
const isDeveloper = useCallback(() => {
return appUser?.email === 'admtracksteel@gmail.com';
}, [appUser]);
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 !== null) || isDeveloper(), [appUser, isDeveloper]);
return (
<AuthContext.Provider
value={{
appUser,
isLoading,
isSignedIn: !!appUser,
error,
token,
login,
logout,
isAdmin,
isUser,
isGuest,
isDeveloper,
canEdit,
refetchUser,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,14 @@
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>;
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { useAuth } from './AuthContext';

View File

@@ -0,0 +1,77 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/useAuth';
import api from '../services/api';
import type { INotification } from '../types';
import { NotificationContext } from './NotificationContextState';
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { appUser, isSignedIn } = useAuth();
const orgId = appUser?.organizationId;
const [notifications, setNotifications] = useState<INotification[]>([]);
const [loading, setLoading] = useState(false);
const fetchNotifications = useCallback(async () => {
if (!isSignedIn || !orgId) return;
try {
setLoading(true);
const response = await api.get<INotification[]>('/notifications');
setNotifications(response.data);
} catch (error) {
console.error('Error fetching notifications:', error);
} finally {
setLoading(false);
}
}, [isSignedIn, orgId]);
useEffect(() => {
if (isSignedIn && orgId) {
fetchNotifications();
const interval = setInterval(fetchNotifications, 60000);
return () => clearInterval(interval);
}
}, [isSignedIn, orgId, fetchNotifications]);
const markAsRead = async (id: string) => {
try {
await api.patch(`/notifications/${id}/read`);
setNotifications(prev => prev.map(n => n._id === id ? { ...n, isRead: true } : n));
} catch (error) {
console.error('Error marking notification as read:', error);
}
};
const markAllAsRead = async () => {
try {
await api.post('/notifications/read-all');
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
} catch (error) {
console.error('Error marking all notifications as read:', error);
}
};
const deleteNotification = async (id: string) => {
try {
await api.delete(`/notifications/${id}`);
setNotifications(prev => prev.filter(n => n._id !== id));
} catch (error) {
console.error('Error deleting notification:', error);
}
};
const unreadCount = notifications.filter(n => !n.isRead).length;
return (
<NotificationContext.Provider value={{
notifications,
unreadCount,
loading,
fetchNotifications,
markAsRead,
markAllAsRead,
deleteNotification
}}>
{children}
</NotificationContext.Provider>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,7 @@
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<App />
);

View File

@@ -0,0 +1,408 @@
import React, { useState, useEffect, useCallback } from '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 { appUser, 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 (!appUser) 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);
}
}, [appUser]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleRoleChange = async (userId: string, newRole: UserRole) => {
if (!appUser) 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 (!appUser) 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) 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 {
// Note: In the future, this should upload to our own backend
// For now, we'll keep the UI but mark it as pending backend integration
alert('Funcionalidade de upload de logo em migração para sistema nativo.');
} 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={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.email === appUser?.email;
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">
<div className="bg-surface rounded-2xl p-8 border border-border/40 text-center space-y-4">
<ImageIcon size={48} className="mx-auto text-text-muted opacity-20" />
<h2 className="text-xl font-bold text-text-main">Gestão de Identidade Visual</h2>
<p className="text-text-muted max-w-md mx-auto">
O gerenciamento nativo de logos está sendo implementado. No momento, o logo atual é gerenciado via configurações do sistema.
</p>
</div>
</div>
) : activeTab === 'settings' ? (
<GeometrySettings />
) : activeTab === 'backup' ? (
<BackupRestore />
) : (
<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>
);
};

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