✅ Restauração do código oficial do GPI-JWT-V3
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.agent
|
||||||
|
.antigravity
|
||||||
|
uploads
|
||||||
|
*.pdf
|
||||||
12
.gitignore
vendored
Normal 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
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"scss.lint.unknownAtRules": "ignore",
|
||||||
|
"less.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
15
Dockerfile
Normal 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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
Force Refresh Vercel - Timestamp: 2026-01-25 13:55
|
||||||
|
Commit Hash Target: 30f8b5c
|
||||||
59
api/app.ts
Normal 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
@@ -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
@@ -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
@@ -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
30
check-pass.cjs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnazFAT947fR60wT20somOC5U9SDdbyTePYfsX+QFWi antigravity
|
||||||
21
index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/steelpaint_iconw.png" media="(prefers-color-scheme: light)" />
|
||||||
|
<link rel="icon" type="image/png" href="/steelpaint_icon.png" media="(prefers-color-scheme: dark)" />
|
||||||
|
<!-- Fallback for browsers without media query support -->
|
||||||
|
<link rel="icon" type="image/png" href="/steelpaint_icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="apple-touch-icon" href="/pwa-192x192.png">
|
||||||
|
<title>GPI - JWT VERSION 1.6</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/client/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
BIN
industrial_painting_flowchart.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
41
migrate_clerk_fields.cjs
Normal 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
@@ -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
@@ -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
75
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
294
pintura-industrial-variaveis.md
Normal 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
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/Engenharia_da_Durabilidade_Industrial.pdf
Normal file
BIN
public/GPI-processos_geral.png
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
BIN
public/maskable-icon.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
public/steelpaint_icon.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
public/steelpaint_iconw.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/steelpaint_iconw.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
BIN
src/client/assets/GPI-processos_geral.png
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
BIN
src/client/assets/LogoSteelPaint.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/client/assets/grade.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
src/client/assets/industrial_painting_flowchart.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
1
src/client/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
40
src/client/components/ActiveUsers.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePresence } from '../hooks/usePresence';
|
||||||
|
|
||||||
|
export const ActiveUsers: React.FC = () => {
|
||||||
|
const { activeUsers } = usePresence();
|
||||||
|
|
||||||
|
// Filter out current user from display if desired, or keep them to show connectivity.
|
||||||
|
// The backend `getActiveUsers` optionally excludes self, but let's handle display logic here.
|
||||||
|
// Backend logic: _id: { $ne: currentUserId }
|
||||||
|
// So activeUsers only contains OTHER users.
|
||||||
|
|
||||||
|
if (activeUsers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex -space-x-2 overflow-hidden py-1">
|
||||||
|
{activeUsers.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u._id}
|
||||||
|
className="relative inline-block group cursor-help"
|
||||||
|
title={`${u.name} (Online)`}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary/10 border-2 border-surface text-xs font-bold flex items-center justify-center text-primary uppercase shadow-sm">
|
||||||
|
{u.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span className="absolute bottom-0 right-0 block h-2.5 w-2.5 rounded-full bg-green-500 ring-2 ring-surface transform translate-y-1/4 translate-x-1/4"></span>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-black text-white text-[10px] rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
|
||||||
|
{u.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeUsers.length > 3 && (
|
||||||
|
<span className="text-[10px] text-text-muted font-bold ml-1">+{activeUsers.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
197
src/client/components/AdhesionGradeSelect.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { HelpCircle, X } from 'lucide-react';
|
||||||
|
import gradeImage from '../assets/grade.jpg';
|
||||||
|
|
||||||
|
interface AdhesionGrade {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
areaRemoved: string;
|
||||||
|
status: 'approved' | 'warning' | 'rejected' | 'critical';
|
||||||
|
statusLabel: string;
|
||||||
|
description: string;
|
||||||
|
spriteY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adhesionGrades: AdhesionGrade[] = [
|
||||||
|
{
|
||||||
|
value: '5B',
|
||||||
|
label: '5B / 5Y',
|
||||||
|
areaRemoved: '0%',
|
||||||
|
status: 'approved',
|
||||||
|
statusLabel: 'Aprovado',
|
||||||
|
description: 'As bordas dos cortes estão completamente lisas; nenhum quadradinho da grade se soltou. A grade parece intacta, apenas riscos finos.',
|
||||||
|
spriteY: 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '4B',
|
||||||
|
label: '4B / 4Y',
|
||||||
|
areaRemoved: '< 5%',
|
||||||
|
status: 'approved',
|
||||||
|
statusLabel: 'Geralmente Aprovado',
|
||||||
|
description: 'Pequenas lascas de tinta se soltaram nas interseções dos cortes. A área afetada é inferior a 5% da área total da grade.',
|
||||||
|
spriteY: 29
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '3B',
|
||||||
|
label: '3B / 3Y',
|
||||||
|
areaRemoved: '5 - 15%',
|
||||||
|
status: 'warning',
|
||||||
|
statusLabel: 'Limite Aceitável',
|
||||||
|
description: 'Pequenas lascas se soltaram ao longo das bordas e nas interseções. As linhas de corte parecem irregulares e alguns cantinhos dos quadrados sumiram.',
|
||||||
|
spriteY: 44
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '2B',
|
||||||
|
label: '2B / 2Y',
|
||||||
|
areaRemoved: '15 - 35%',
|
||||||
|
status: 'rejected',
|
||||||
|
statusLabel: 'Geralmente Reprovado',
|
||||||
|
description: 'A tinta descascou ao longo das bordas e em partes dos quadrados. É visível que a tinta está falhando; faixas inteiras ao lado dos cortes podem ter saído.',
|
||||||
|
spriteY: 59
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '1B',
|
||||||
|
label: '1B / 1Y',
|
||||||
|
areaRemoved: '35 - 65%',
|
||||||
|
status: 'rejected',
|
||||||
|
statusLabel: 'Reprovado',
|
||||||
|
description: 'A tinta descascou em fitas largas ou quadrados inteiros se soltaram. A grade está muito danificada, com grandes buracos.',
|
||||||
|
spriteY: 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '0B',
|
||||||
|
label: '0B / 0Y',
|
||||||
|
areaRemoved: '> 65%',
|
||||||
|
status: 'critical',
|
||||||
|
statusLabel: 'Reprovado Crítico',
|
||||||
|
description: 'A descamação e remoção é pior que o grau 1B (mais de 65% da área). A maior parte da tinta na área do teste foi arrancada pela fita.',
|
||||||
|
spriteY: 89
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AdhesionGradeSelectProps {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdhesionGradeSelect: React.FC<AdhesionGradeSelectProps> = ({ name, label, value, onChange }) => {
|
||||||
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
|
|
||||||
|
const getStatusColor = (status: AdhesionGrade['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved': return 'text-green-400 bg-green-500/20 border-green-500/30';
|
||||||
|
case 'warning': return 'text-amber-400 bg-amber-500/20 border-amber-500/30';
|
||||||
|
case 'rejected': return 'text-red-400 bg-red-500/20 border-red-500/30';
|
||||||
|
case 'critical': return 'text-red-500 bg-red-600/30 border-red-600/40';
|
||||||
|
default: return 'text-gray-400 bg-gray-500/20 border-gray-500/30';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col gap-1 w-full">
|
||||||
|
{/* Label with help button */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
{label && <label className="text-[10px] font-bold text-primary dark:text-primary-light uppercase tracking-[0.15em] ml-1">{label}</label>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowGuide(true)}
|
||||||
|
className="text-primary hover:text-primary/80 transition-colors"
|
||||||
|
title="Ver guia de classificação ASTM D3359"
|
||||||
|
>
|
||||||
|
<HelpCircle size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name={name}
|
||||||
|
title={label || 'Teste de Aderência'}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="flex h-12 w-full rounded-xl border px-4 py-2 text-sm transition-all font-medium shadow-inner cursor-pointer appearance-none outline-none bg-no-repeat bg-[length:1.5em_1.5em] bg-[right_0.75rem_center] bg-[url('data:image/svg+xml,%3Csvg_xmlns=%27http://www.w3.org/2000/svg%27_fill=%27none%27_viewBox=%270_0_20_20%27%3E%3Cpath_stroke=%27%23fb923c%27_stroke-linecap=%27round%27_stroke-linejoin=%27round%27_stroke-width=%271.5%27_d=%27m6_8_4_4_4-4%27/%3E%3C/svg%3E')] bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">-- Selecione --</option>
|
||||||
|
{adhesionGrades.map((grade) => (
|
||||||
|
<option key={grade.value} value={grade.value}>
|
||||||
|
{grade.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Guide Modal */}
|
||||||
|
{showGuide && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
|
||||||
|
onClick={() => setShowGuide(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="fixed inset-4 sm:inset-8 md:inset-12 lg:inset-20 z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-4xl max-h-full overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border bg-surface-soft">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-main">Guia de Classificação ASTM D3359</h2>
|
||||||
|
<p className="text-xs text-text-muted">Método B - Teste de Aderência de Corte em Grade</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowGuide(false)}
|
||||||
|
className="p-2 rounded-lg hover:bg-surface-hover text-text-muted hover:text-text-main transition-all"
|
||||||
|
title="Fechar guia"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{/* Grade Image */}
|
||||||
|
<div className="mb-6 rounded-xl overflow-hidden border border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={gradeImage}
|
||||||
|
alt="Guia Visual ASTM D3359"
|
||||||
|
className="w-full object-contain max-h-64 sm:max-h-80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grade Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{adhesionGrades.map((grade) => (
|
||||||
|
<div
|
||||||
|
key={grade.value}
|
||||||
|
className={`p-3 rounded-xl border ${getStatusColor(grade.status)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-base font-bold">{grade.value}</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase opacity-80">
|
||||||
|
{grade.areaRemoved}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-semibold mb-1">{grade.statusLabel}</p>
|
||||||
|
<p className="text-[10px] opacity-80 leading-relaxed">
|
||||||
|
{grade.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Note */}
|
||||||
|
<div className="mt-4 p-3 rounded-xl bg-primary/10 border border-primary/20">
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
<strong className="text-primary">Nota:</strong> A escala ASTM funciona como uma "nota escolar":
|
||||||
|
<strong className="text-green-400"> 5B é a nota máxima (perfeito)</strong> e
|
||||||
|
<strong className="text-red-400"> 0B é a nota mínima (reprovado)</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
131
src/client/components/ArchivedNotificationsModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Archive, Trash2, RefreshCcw } from 'lucide-react';
|
||||||
|
import api from '../services/api';
|
||||||
|
import type { INotification } from '../types';
|
||||||
|
|
||||||
|
interface ArchivedNotificationsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArchivedNotificationsModal: React.FC<ArchivedNotificationsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [notifications, setNotifications] = useState<INotification[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchArchived = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get<INotification[]>('/notifications?includeArchived=true');
|
||||||
|
// Filtrar apenas as arquivadas (no frontend por segurança, embora o backend já devesse ajudar)
|
||||||
|
// Na verdade, passamos includeArchived=true, o backend retornará unread + archived.
|
||||||
|
// Vamos filtrar para mostrar apenas o "Log" (arquivadas).
|
||||||
|
setNotifications(response.data.filter(n => n.isArchived || n.archivedBy?.length > 0));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching archived notifications:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchArchived();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const deleteForever = async (id: string) => {
|
||||||
|
if (!window.confirm('Excluir permanentemente este registro do log?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/notifications/${id}`);
|
||||||
|
setNotifications(prev => prev.filter(n => n._id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting archived notification:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl shadow-2xl max-w-2xl w-full border border-zinc-800 animate-in slide-in-from-bottom-4 max-h-[80vh] flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-zinc-800 bg-zinc-900">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-zinc-800 flex items-center justify-center">
|
||||||
|
<Archive className="text-zinc-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Log de Mensagens (Arquivadas)</h2>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Histórico de notificações sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-zinc-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-zinc-950/50">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<RefreshCcw className="animate-spin text-primary mx-auto mb-2" size={24} />
|
||||||
|
<p className="text-zinc-500">Carregando histórico...</p>
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Archive size={48} className="text-zinc-800 mx-auto mb-4" />
|
||||||
|
<p className="text-zinc-500 font-semibold">Nenhuma mensagem arquivada</p>
|
||||||
|
<p className="text-zinc-600 text-sm mt-1">
|
||||||
|
Mensagens arquivadas aparecerão aqui para consulta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{notifications.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg._id}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-xl p-4 hover:border-zinc-700 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-zinc-200 text-sm">{msg.title}</span>
|
||||||
|
<span className="text-[10px] text-zinc-500">
|
||||||
|
{new Date(msg.createdAt).toLocaleString('pt-BR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteForever(msg._id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 p-1.5 text-zinc-600 hover:text-red-500 transition-all"
|
||||||
|
title="Excluir permanentemente"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-400 text-xs leading-relaxed">{msg.message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-zinc-800 bg-zinc-900 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg font-bold text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/client/components/Button.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center rounded-xl font-bold transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 tracking-wide';
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-primary text-background-dark shadow-lg shadow-primary/20 hover:shadow-glow-primary hover:bg-primary/90 focus:ring-primary font-black',
|
||||||
|
secondary: 'bg-surface-highlight/50 backdrop-blur-sm text-text-main border border-white/10 hover:bg-surface-hover hover:border-text-muted/30 focus:ring-accent',
|
||||||
|
success: 'bg-accent-green text-background-dark shadow-lg shadow-accent-green/20 hover:shadow-[0_0_20px_rgba(11,218,77,0.4)] hover:bg-accent-green/90 focus:ring-accent-green font-black',
|
||||||
|
danger: 'bg-error text-white shadow-lg shadow-error/20 hover:shadow-glow-error hover:bg-error/90 focus:ring-error font-bold',
|
||||||
|
ghost: 'bg-transparent hover:bg-white/5 text-text-muted hover:text-white'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-9 px-4 text-xs',
|
||||||
|
md: 'h-11 px-6 py-2.5 text-sm',
|
||||||
|
lg: 'h-14 px-8 text-base shadow-xl'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={twMerge(baseStyles, variants[variant], sizes[size], className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/client/components/Card.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card: React.FC<CardProps> = ({ children, className, title, description, actions, onClick }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={twMerge(
|
||||||
|
'glass-card rounded-[24px] p-6 border border-white/5',
|
||||||
|
'hover:border-primary/20 hover:shadow-lg transition-all duration-500',
|
||||||
|
onClick ? 'cursor-pointer active:scale-[0.98]' : '',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(title || actions) && (
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div>
|
||||||
|
{title && <h3 className="text-xl font-bold text-text-main tracking-tight">{title}</h3>}
|
||||||
|
{description && <p className="text-sm text-text-muted font-medium mt-1">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-text-main">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
36
src/client/components/ColorBubble.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface ColorBubbleProps {
|
||||||
|
colorHex?: string;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorBubble: React.FC<ColorBubbleProps> = ({ colorHex, className, title }) => {
|
||||||
|
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (bubbleRef.current) {
|
||||||
|
if (colorHex) {
|
||||||
|
bubbleRef.current.style.setProperty('--bg-color', colorHex);
|
||||||
|
} else {
|
||||||
|
bubbleRef.current.style.removeProperty('--bg-color');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [colorHex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={bubbleRef}
|
||||||
|
className={clsx(
|
||||||
|
"w-4 h-4 rounded-full border shadow-inner transition-all duration-300 flex-shrink-0",
|
||||||
|
colorHex
|
||||||
|
? "border-border/40 dynamic-bg-color"
|
||||||
|
: "border-border/20 bg-transparent opacity-20 border-dashed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={title || (colorHex ? `Cor: ${colorHex}` : 'Cor não definida')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/client/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { AlertTriangle, HelpCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
type?: 'danger' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText = 'Confirmar',
|
||||||
|
cancelText = 'Cancelar',
|
||||||
|
type = 'info'
|
||||||
|
}) => {
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'danger': return <AlertTriangle className="w-6 h-6 text-error" />;
|
||||||
|
case 'warning': return <AlertTriangle className="w-6 h-6 text-orange-500" />;
|
||||||
|
default: return <HelpCircle className="w-6 h-6 text-primary" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||||
|
<div className="space-y-6 pt-2">
|
||||||
|
<div className="flex gap-4 p-4 rounded-2xl bg-surface-soft border border-border/40">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-secondary leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={type === 'danger' ? 'danger' : 'primary'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
src/client/components/Input.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
375
src/client/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
117
src/client/components/MobileList.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card } from './Card';
|
||||||
|
|
||||||
|
interface Column<T> {
|
||||||
|
header: string;
|
||||||
|
accessor: keyof T | ((item: T) => React.ReactNode);
|
||||||
|
className?: string; // For adding widths or hiding on smaller screens
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileListProps<T> {
|
||||||
|
data: T[];
|
||||||
|
columns: Column<T>[];
|
||||||
|
keyExtractor: (item: T) => string;
|
||||||
|
onItemClick?: (item: T) => void;
|
||||||
|
titleAccessor?: keyof T | ((item: T) => React.ReactNode);
|
||||||
|
subtitleAccessor?: keyof T | ((item: T) => React.ReactNode);
|
||||||
|
actionRender?: (item: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileList = <T,>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
keyExtractor,
|
||||||
|
onItemClick,
|
||||||
|
titleAccessor,
|
||||||
|
subtitleAccessor,
|
||||||
|
actionRender
|
||||||
|
}: MobileListProps<T>) => {
|
||||||
|
|
||||||
|
const renderCell = (item: T, col: Column<T>) => {
|
||||||
|
if (typeof col.accessor === 'function') {
|
||||||
|
return col.accessor(item);
|
||||||
|
}
|
||||||
|
return item[col.accessor] as React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = (item: T) => {
|
||||||
|
if (!titleAccessor) return undefined;
|
||||||
|
if (typeof titleAccessor === 'function') return titleAccessor(item) as string;
|
||||||
|
return item[titleAccessor] as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubtitle = (item: T) => {
|
||||||
|
if (!subtitleAccessor) return undefined;
|
||||||
|
if (typeof subtitleAccessor === 'function') return subtitleAccessor(item) as string;
|
||||||
|
return item[subtitleAccessor] as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile View: Cards */}
|
||||||
|
<div className="md:hidden flex flex-col gap-4">
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<Card
|
||||||
|
key={keyExtractor(item) || `mobile-${index}`}
|
||||||
|
onClick={() => onItemClick?.(item)}
|
||||||
|
title={getTitle(item)}
|
||||||
|
description={getSubtitle(item)}
|
||||||
|
actions={actionRender?.(item)}
|
||||||
|
className="active:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{columns.map((col, colIndex) => {
|
||||||
|
// Skip if it's the title or subtitle to avoid duplication (optional logic)
|
||||||
|
return (
|
||||||
|
<div key={colIndex} className="flex justify-between text-sm py-1 border-b border-dashed border-border last:border-0">
|
||||||
|
<span className="font-medium text-text-secondary">{col.header}:</span>
|
||||||
|
<span className="text-text-main text-right">{renderCell(item, col)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop View: Table */}
|
||||||
|
<div className="hidden md:block overflow-hidden rounded-2xl border border-border/40 shadow-soft bg-surface">
|
||||||
|
<table className="min-w-full divide-y divide-border/40">
|
||||||
|
<thead className="bg-surface-soft/50">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<th
|
||||||
|
key={idx}
|
||||||
|
className={`px-4 py-5 text-left text-[10px] font-bold text-text-muted uppercase tracking-[0.2em] ${col.className || ''}`}
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{actionRender && <th className="px-4 py-5 text-right text-[10px] font-bold text-text-muted uppercase tracking-[0.2em]">Ações</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-surface divide-y divide-border/40">
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<tr
|
||||||
|
key={keyExtractor(item) || `row-${index}`}
|
||||||
|
onClick={() => onItemClick?.(item)}
|
||||||
|
className="hover:bg-primary/[0.02] transition-colors cursor-pointer group"
|
||||||
|
>
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<td key={idx} className="px-4 py-5 whitespace-nowrap text-sm text-text-main font-medium group-hover:text-primary">
|
||||||
|
{renderCell(item, col)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{actionRender && (
|
||||||
|
<td className="px-4 py-5 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
{actionRender(item)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
src/client/components/Modal.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
maxWidth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, maxWidth = 'max-w-2xl' }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||||
|
<div className={`bg-surface rounded-2xl shadow-[var(--shadow-premium)] w-full ${maxWidth} max-h-[90vh] flex flex-col animate-in fade-in zoom-in duration-200 border border-border/40 relative`}>
|
||||||
|
<div className="flex justify-between items-center p-5 border-b border-border">
|
||||||
|
<h2 className="text-xl font-bold text-text-main">{title}</h2>
|
||||||
|
<button onClick={onClose} className="p-2 text-text-secondary hover:bg-surface-hover hover:text-text-main rounded-lg transition-colors" aria-label="Fechar modal">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
199
src/client/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useNotifications } from '../hooks/useNotifications';
|
||||||
|
import { Bell, Check, Info, AlertTriangle, AlertCircle, CheckCircle, Trash2, Archive } from 'lucide-react';
|
||||||
|
import type { NotificationType, INotification } from '../types';
|
||||||
|
import { ArchivedNotificationsModal } from './ArchivedNotificationsModal';
|
||||||
|
|
||||||
|
const NotificationBell: React.FC = () => {
|
||||||
|
const {
|
||||||
|
unreadCount,
|
||||||
|
notifications,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
clearAll,
|
||||||
|
archiveNotification,
|
||||||
|
deleteNotification
|
||||||
|
} = useNotifications();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false);
|
||||||
|
const [confirmActionId, setConfirmActionId] = useState<string | null>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close popover when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [popoverRef]);
|
||||||
|
|
||||||
|
const getIcon = (type: NotificationType) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'info': return <Info size={16} className="text-blue-500" />;
|
||||||
|
case 'warning': return <AlertTriangle size={16} className="text-yellow-500" />;
|
||||||
|
case 'error': return <AlertCircle size={16} className="text-red-500" />;
|
||||||
|
case 'success': return <CheckCircle size={16} className="text-green-500" />;
|
||||||
|
default: return <Info size={16} className="text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={popoverRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`p-2 rounded-full transition-colors relative ${isOpen ? 'bg-zinc-800 text-white' : 'hover:bg-zinc-800 text-zinc-400'
|
||||||
|
}`}
|
||||||
|
title="Notificações"
|
||||||
|
>
|
||||||
|
<div className={unreadCount > 0 ? "animate-pulse text-red-500" : ""}>
|
||||||
|
<Bell size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 bottom-full mb-2 w-80 sm:w-96 bg-zinc-900 border border-zinc-800 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||||
|
<div className="p-3 border-b border-zinc-800 flex justify-between items-center bg-zinc-900">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-100">Notificações</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => markAllAsRead()}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1"
|
||||||
|
title="Marcar todas como lidas"
|
||||||
|
>
|
||||||
|
<Check size={12} /> Lidas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Deseja limpar todas as notificações?')) {
|
||||||
|
clearAll();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs text-zinc-500 hover:text-red-400 transition-colors flex items-center gap-1"
|
||||||
|
title="Limpar tudo"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} /> Limpar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsArchiveModalOpen(true)}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-200 transition-colors flex items-center gap-1"
|
||||||
|
title="Ver Arquivadas"
|
||||||
|
>
|
||||||
|
<Archive size={12} /> Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-zinc-500 text-sm">
|
||||||
|
Nenhuma notificação.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{notifications.map((notification: INotification) => (
|
||||||
|
<li
|
||||||
|
key={notification._id}
|
||||||
|
className={`p-3 border-b border-zinc-800 hover:bg-zinc-800/50 transition-colors flex gap-3 ${!notification.isRead ? 'bg-zinc-800/20' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mt-1 flex-shrink-0">
|
||||||
|
{getIcon(notification.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className={`text-sm font-medium ${!notification.isRead ? 'text-white' : 'text-zinc-400'}`}>
|
||||||
|
{notification.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-zinc-500 whitespace-nowrap ml-2">
|
||||||
|
{formatDate(notification.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
{!notification.isRead ? (
|
||||||
|
<button
|
||||||
|
onClick={() => markAsRead(notification._id)}
|
||||||
|
className="text-[10px] text-blue-400 hover:text-blue-300 flex items-center gap-1 font-semibold"
|
||||||
|
>
|
||||||
|
Marcar como lida
|
||||||
|
</button>
|
||||||
|
) : <div />}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmActionId(notification._id)}
|
||||||
|
className="text-zinc-500 hover:text-red-400 transition-colors p-1"
|
||||||
|
title="Opções de exclusão"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt de Arquivar/Excluir */}
|
||||||
|
{confirmActionId === notification._id && (
|
||||||
|
<div className="absolute inset-0 bg-zinc-900/95 flex flex-col items-center justify-center p-4 z-10 rounded-lg animate-in fade-in">
|
||||||
|
<p className="text-xs font-bold text-white mb-3 text-center">O que deseja fazer com esta mensagem?</p>
|
||||||
|
<div className="flex gap-2 w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
archiveNotification(notification._id);
|
||||||
|
setConfirmActionId(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-2 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-white rounded text-[10px] font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Arquivar (Log)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
deleteNotification(notification._id);
|
||||||
|
setConfirmActionId(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-2 py-1.5 bg-red-600/20 hover:bg-red-600/40 text-red-500 rounded text-[10px] font-bold border border-red-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Apagar Devez
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmActionId(null)}
|
||||||
|
className="mt-3 text-[10px] text-zinc-500 hover:text-zinc-300 underline"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ArchivedNotificationsModal
|
||||||
|
isOpen={isArchiveModalOpen}
|
||||||
|
onClose={() => setIsArchiveModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationBell;
|
||||||
112
src/client/components/PhotoUpload.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { Camera, X } from 'lucide-react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
interface PhotoUploadProps {
|
||||||
|
photos: string[];
|
||||||
|
onPhotosChange: (url: string) => void;
|
||||||
|
onRemovePhoto: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ photos, onPhotosChange, onRemovePhoto }) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validation: Max 500KB
|
||||||
|
if (file.size > 500 * 1024) {
|
||||||
|
alert('A foto deve ter no máximo 500KB.');
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation: JPG only
|
||||||
|
if (file.type !== 'image/jpeg' && file.type !== 'image/jpg') {
|
||||||
|
alert('Apenas fotos no formato JPG são permitidas.');
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/inspections/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onPhotosChange(response.data.url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading photo:', error);
|
||||||
|
alert('Erro ao enviar foto. Verifique se o tamanho é menor que 500KB.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1">Fotos da Inspeção</span>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<div key={index} className="relative group aspect-square rounded-xl overflow-hidden border border-border/40 bg-surface-soft">
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt={`Evidência ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemovePhoto(index)}
|
||||||
|
className="p-2 bg-error text-white rounded-full hover:bg-error/90 transition-colors"
|
||||||
|
aria-label={`Remover foto ${index + 1}`}
|
||||||
|
title={`Remover foto ${index + 1}`}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="aspect-square flex flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-border/60 hover:border-primary/50 bg-surface hover:bg-surface-hover transition-all text-text-muted hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Adicionar foto"
|
||||||
|
title="Adicionar foto (JPG, Max 500KB)"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Camera size={24} className="mb-1" />
|
||||||
|
<span className="text-xs font-bold uppercase">Adicionar</span>
|
||||||
|
<span className="text-[9px] font-medium opacity-70">Máx 500kb (JPG)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
accept="image/jpeg, image/jpg"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
aria-label="Selecionar arquivo de foto"
|
||||||
|
title="Selecionar arquivo de foto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/client/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import type { UserRole } from '../types';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allowedRoles?: UserRole[];
|
||||||
|
requireEdit?: boolean;
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProtectedRoute component that restricts access based on user role
|
||||||
|
* @param allowedRoles - Array of roles that can access the route
|
||||||
|
* @param requireEdit - If true, only users who can edit (not guests) can access
|
||||||
|
* @param redirectTo - Where to redirect if access is denied (default: '/')
|
||||||
|
*/
|
||||||
|
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||||
|
children,
|
||||||
|
allowedRoles,
|
||||||
|
requireEdit = false,
|
||||||
|
redirectTo = '/',
|
||||||
|
}) => {
|
||||||
|
const { appUser, isLoading, canEdit } = useAuth();
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<RefreshCw size={32} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role-based access
|
||||||
|
if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) {
|
||||||
|
return <Navigate to={redirectTo} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check edit permission
|
||||||
|
if (requireEdit && !canEdit()) {
|
||||||
|
return <Navigate to={redirectTo} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
36
src/client/components/Select.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
options: { label: string; value: string | number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(({ className, label, error, options, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
{label && <label className="text-[10px] font-bold text-primary dark:text-primary-light uppercase tracking-[0.15em] ml-1 mb-1">{label}</label>}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={twMerge(
|
||||||
|
'flex h-12 w-full rounded-xl border px-4 py-2 text-sm transition-all font-medium shadow-sm appearance-none outline-none',
|
||||||
|
'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)]',
|
||||||
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
error && 'border-error focus:ring-error/10 focus:border-error',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<option value="">-- Selecione --</option>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <span className="text-sm text-error">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
154
src/client/components/SendMessageModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { X, Send, Trash2 } from 'lucide-react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
interface SendMessageModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
recipientId: string;
|
||||||
|
recipientName: string;
|
||||||
|
existingMessage?: {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
onMessageSent: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SendMessageModal: React.FC<SendMessageModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
recipientId,
|
||||||
|
recipientName,
|
||||||
|
existingMessage,
|
||||||
|
onMessageSent,
|
||||||
|
}) => {
|
||||||
|
const [message, setMessage] = useState(existingMessage?.message || '');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim()) {
|
||||||
|
alert('Digite uma mensagem primeiro');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
await api.post('/messages', {
|
||||||
|
toUserId: recipientId,
|
||||||
|
message: message.trim(),
|
||||||
|
});
|
||||||
|
alert('Mensagem enviada com sucesso!');
|
||||||
|
onMessageSent();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error);
|
||||||
|
alert('Erro ao enviar mensagem');
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!existingMessage?.id) return;
|
||||||
|
|
||||||
|
if (!confirm('Deseja realmente deletar esta mensagem?')) return;
|
||||||
|
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/messages/${existingMessage.id}`);
|
||||||
|
alert('Mensagem deletada com sucesso!');
|
||||||
|
onMessageSent();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting message:', error);
|
||||||
|
alert('Erro ao deletar mensagem');
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-surface rounded-2xl shadow-2xl max-w-md w-full border border-border/40 animate-in slide-in-from-bottom-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border/40">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-main">
|
||||||
|
{existingMessage ? 'Editar Mensagem' : 'Enviar Mensagem'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-text-muted mt-1">
|
||||||
|
Para: <span className="font-semibold text-primary">{recipientName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-surface-hover rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-text-muted" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-text-main mb-2">
|
||||||
|
Mensagem
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 bg-surface-soft border border-border/40 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/50 text-text-main resize-none"
|
||||||
|
placeholder="Digite sua mensagem (máximo 255 caracteres)..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-text-muted text-right mt-1">
|
||||||
|
{message.length}/255 caracteres
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{existingMessage && (
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
ℹ️ Você já enviou uma mensagem para este usuário. Esta ação vai substituir a mensagem anterior (se ainda não foi lida).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-t border-border/40 bg-surface-soft/50">
|
||||||
|
{existingMessage && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isSending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-error/10 hover:bg-error/20 text-error rounded-lg font-semibold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Deletar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center gap-2 ${existingMessage ? '' : 'ml-auto'}`}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 hover:bg-surface-hover rounded-lg font-semibold text-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isSending || !message.trim()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
{isSending ? 'Enviando...' : 'Enviar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
184
src/client/components/TeamPresence.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
617
src/client/components/TechnicalManual.tsx
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { X, Search, BookOpen, FileText, ChevronRight, ChevronDown, ExternalLink, Wrench, Droplets, Thermometer, CheckSquare, Shield, Layers, Gauge, Ruler, Calculator } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TechnicalManualProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManualSection {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
content: React.ReactNode;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TechnicalManual: React.FC<TechnicalManualProps> = ({ isOpen, onClose }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [activeSection, setActiveSection] = useState<string | null>('intro');
|
||||||
|
const [showIndex, setShowIndex] = useState(false);
|
||||||
|
|
||||||
|
// Seções do Manual
|
||||||
|
const sections: ManualSection[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: 'intro',
|
||||||
|
title: 'Introdução ao GPI',
|
||||||
|
icon: BookOpen,
|
||||||
|
keywords: ['introdução', 'gpi', 'sistema', 'gestão', 'pintura', 'industrial'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<img
|
||||||
|
src="/GPI-processos_geral.png"
|
||||||
|
alt="Processos GPI"
|
||||||
|
className="w-full rounded-2xl border border-border/40 shadow-lg"
|
||||||
|
/>
|
||||||
|
<h3 className="text-xl font-black text-primary">Bem-vindo ao GPI</h3>
|
||||||
|
<p className="text-text-secondary leading-relaxed">
|
||||||
|
O <strong>GPI (Gestão de Pintura Industrial)</strong> é um sistema completo para gerenciamento de obras,
|
||||||
|
projetos e processos de pintura industrial em estruturas metálicas. O sistema permite controlar desde
|
||||||
|
a preparação da superfície até a inspeção final de aderência e espessura.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="p-4 bg-primary/5 rounded-xl border border-primary/20">
|
||||||
|
<h4 className="font-bold text-primary mb-2">Módulos Disponíveis:</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-text-secondary">
|
||||||
|
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Obras & Projetos:</strong> Gestão de projetos e cronogramas</li>
|
||||||
|
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Peças:</strong> Cadastro e acompanhamento de peças</li>
|
||||||
|
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Esquemas:</strong> Definição de esquemas de pintura</li>
|
||||||
|
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Inspeções:</strong> Registro e controle de inspeções</li>
|
||||||
|
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Biblioteca Técnica:</strong> Fichas técnicas de tintas</li>
|
||||||
|
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Estudo de Rendimento:</strong> Cálculo de consumo de tintas</li>
|
||||||
|
<li className="flex items-center gap-2"><ChevronRight size={14} className="text-primary" /> <strong>Calculadora:</strong> Utilitários técnicos e conversões</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'calculator',
|
||||||
|
title: 'Calculadora',
|
||||||
|
icon: Calculator,
|
||||||
|
keywords: ['calculadora', 'conversão', 'espessura', 'consumo', 'custo', 'bicos', 'airless', 'ambiente'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">Ferramentas & Cálculos</h3>
|
||||||
|
<p className="text-text-secondary leading-relaxed">
|
||||||
|
O módulo de Calculadora oferece um conjunto de utilitários técnicos essenciais para o dia a dia
|
||||||
|
do inspetor e gestor de pintura, centralizando conversões e cálculos complexos.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">1. Conversões</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Conversor preciso entre unidades comuns na indústria:
|
||||||
|
<br />• Microns (μm) ↔ Mils (milésimos de polegada)
|
||||||
|
<br />• PSI ↔ Bar (Pressão)
|
||||||
|
<br />• Celsius (°C) ↔ Fahrenheit (°F)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">2. Espessura de Película</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Calculadoras para determinar:
|
||||||
|
<br />• <strong>EPS Estimada:</strong> Baseado na EPU e % Sólidos.
|
||||||
|
<br />• <strong>EPU Necessária:</strong> Quanto aplicar úmido para atingir a espessura seca desejada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">3. Consumo & Custo</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Ferramenta rápida para estimar tinta necessária:
|
||||||
|
<br />• Cálculo por Área (m²) e Espessura.
|
||||||
|
<br />• Inclusão de fator de perda (Eficiência).
|
||||||
|
<br />• Estimativa de custo total por demão.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">4. Bicos Airless</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Decodificador de códigos de bicos airless (ex: 517):
|
||||||
|
<br />• <strong>Ângulo do leque:</strong> (1º digito × 10) graus.
|
||||||
|
<br />• <strong>Vazão/Orifício:</strong> (2 últimos digitos) milésimos.
|
||||||
|
<br />• Recomendação de uso por tipo de tinta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40 md:col-span-2">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">5. Condições Ambientais</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Verificação rápida de conformidade climática:
|
||||||
|
<br />• Cálculo automático do <strong>Ponto de Orvalho</strong> (baseado em Temp e UR).
|
||||||
|
<br />• Verificação da regra "Temperatura da Superfície {'>'} Ponto de Orvalho + 3°C".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yield-study',
|
||||||
|
title: 'Estudo de Rendimento',
|
||||||
|
icon: Gauge,
|
||||||
|
keywords: ['rendimento', 'consumo', 'tinta', 'cálculo', 'peso', 'área', 'litros', 'eficiência'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">Cálculo de Consumo de Tintas</h3>
|
||||||
|
<p className="text-text-secondary leading-relaxed">
|
||||||
|
O módulo de Estudo de Rendimento permite calcular com precisão a quantidade de tinta necessária
|
||||||
|
para um projeto, considerando dois métodos de cálculo: por peso (toneladas) e por área (m²).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-primary/5 rounded-xl border border-primary/20">
|
||||||
|
<h4 className="font-bold text-primary mb-2">Cálculo por Peso</h4>
|
||||||
|
<p className="text-xs text-text-muted">Baseado na taxa histórica (L/t) multiplicada pelo peso em toneladas.</p>
|
||||||
|
<code className="block mt-2 text-xs bg-surface p-2 rounded">
|
||||||
|
Consumo = Peso × Taxa × (1 / Eficiência)
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-blue-500/5 rounded-xl border border-blue-500/20">
|
||||||
|
<h4 className="font-bold text-blue-500 mb-2">Cálculo por Área</h4>
|
||||||
|
<p className="text-xs text-text-muted">Baseado na área e espessura de película seca (DFT).</p>
|
||||||
|
<code className="block mt-2 text-xs bg-surface p-2 rounded">
|
||||||
|
Consumo = (Área × DFT) / (SV × 10)
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
|
||||||
|
<h4 className="font-bold text-amber-600 mb-2">⚠️ Importante: Eficiência</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
A eficiência (ex: 80%) representa as perdas no processo de aplicação. Um fator de perda
|
||||||
|
é aplicado automaticamente aos cálculos: se eficiência = 80%, o consumo real será 25% maior que o teórico.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'surface-prep',
|
||||||
|
title: 'Preparação de Superfície',
|
||||||
|
icon: Wrench,
|
||||||
|
keywords: ['preparação', 'superfície', 'jateamento', 'limpeza', 'rugosidade', 'abrasivo', 'grau'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">A Etapa Mais Crítica</h3>
|
||||||
|
<p className="text-text-secondary leading-relaxed">
|
||||||
|
Estudos científicos demonstram que entre <strong>80% e 90%</strong> do sucesso de um sistema de pintura
|
||||||
|
depende da qualidade do preparo da superfície metálica.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">Graus de Limpeza (ISO 8501-1)</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-text-secondary">
|
||||||
|
<li><strong>St 2:</strong> Limpeza manual com ferramentas</li>
|
||||||
|
<li><strong>Sa 2:</strong> Jateamento comercial</li>
|
||||||
|
<li><strong>Sa 2.5:</strong> Jateamento ao metal quase branco (mais comum)</li>
|
||||||
|
<li><strong>Sa 3:</strong> Jateamento ao metal branco (máxima limpeza)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-primary/5 rounded-xl border border-primary/20">
|
||||||
|
<h4 className="font-bold text-primary mb-2">Perfil de Rugosidade</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
O perfil mínimo de rugosidade deve ser de <strong>25 μm</strong> para um bom sistema de pintura
|
||||||
|
(ISO 8503-1, ABNT NBR 10443). Perfil excessivo (acima de 100 μm) pode criar picos frágeis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'paint-types',
|
||||||
|
title: 'Tipos de Tintas',
|
||||||
|
icon: Droplets,
|
||||||
|
keywords: ['tinta', 'epóxi', 'poliuretano', 'zinco', 'silicato', 'primer', 'acabamento'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">Características das Tintas</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
|
||||||
|
<h4 className="font-bold text-blue-600 mb-2">Tintas Epóxi</h4>
|
||||||
|
<ul className="text-sm text-text-secondary space-y-1">
|
||||||
|
<li>• Alta resistência química e física</li>
|
||||||
|
<li>• Excelente aderência e impermeabilidade</li>
|
||||||
|
<li>• Baixa resistência UV (amarelamento)</li>
|
||||||
|
<li>• Cura: 6-16h para repintura, 7 dias completa</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-green-500/10 rounded-xl border border-green-500/30">
|
||||||
|
<h4 className="font-bold text-green-600 mb-2">Tintas Poliuretano (PU)</h4>
|
||||||
|
<ul className="text-sm text-text-secondary space-y-1">
|
||||||
|
<li>• Resistência superior aos raios UV</li>
|
||||||
|
<li>• Mantém cor e brilho por mais tempo</li>
|
||||||
|
<li>• Excelente resistência à abrasão</li>
|
||||||
|
<li>• Cura: 4-24h para repintura</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
|
||||||
|
<h4 className="font-bold text-amber-600 mb-2">Silicato de Zinco</h4>
|
||||||
|
<ul className="text-sm text-text-secondary space-y-1">
|
||||||
|
<li>• Proteção catódica ativa</li>
|
||||||
|
<li>• Oxidação preferencial do zinco</li>
|
||||||
|
<li>• Requer UR adequada (não acima de 85%)</li>
|
||||||
|
<li>• Cura muito rápida: 20-30 min</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'solids-volume',
|
||||||
|
title: 'Sólidos por Volume (SV)',
|
||||||
|
icon: Layers,
|
||||||
|
keywords: ['sólidos', 'volume', 'sv', 'rendimento', 'teórico', 'fórmula', 'cálculo'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">O Parâmetro Mais Importante</h3>
|
||||||
|
<p className="text-text-secondary leading-relaxed">
|
||||||
|
O <strong>Sólidos por Volume (SV%)</strong> é a porcentagem do volume de tinta que permanece
|
||||||
|
como filme sólido após a evaporação completa de todos os solventes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="p-6 bg-gradient-to-br from-primary/10 to-primary/5 rounded-2xl border border-primary/30">
|
||||||
|
<h4 className="font-bold text-primary mb-4">Fórmula do Rendimento Teórico</h4>
|
||||||
|
<code className="block text-lg bg-surface p-4 rounded-xl text-center font-mono">
|
||||||
|
Rend. Teórico (m²/L) = (SV% × 10) / EPS (μm)
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-text-muted mt-4">
|
||||||
|
Exemplo: Tinta com 60% SV aplicada para 50 μm = (60 × 10) / 50 = <strong>12 m²/L</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
|
||||||
|
<h4 className="font-bold text-amber-600 mb-2">Efeito da Diluição</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
A diluição reduz o SV efetivo. Uma tinta com 60% SV diluída 15% terá SV efetivo de aproximadamente 51%.
|
||||||
|
Normas recomendam não diluir acima de 10-20%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'film-thickness',
|
||||||
|
title: 'Espessura de Película',
|
||||||
|
icon: Ruler,
|
||||||
|
keywords: ['espessura', 'película', 'úmida', 'seca', 'dft', 'wft', 'epu', 'eps', 'microns'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">EPU e EPS</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
|
||||||
|
<h4 className="font-bold text-blue-600 mb-2">EPU (Película Úmida)</h4>
|
||||||
|
<p className="text-xs text-text-muted">WFT - Wet Film Thickness</p>
|
||||||
|
<p className="text-sm text-text-secondary mt-2">
|
||||||
|
Medida imediatamente após aplicação com "pente de campanha".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-500/10 rounded-xl border border-green-500/30">
|
||||||
|
<h4 className="font-bold text-green-600 mb-2">EPS (Película Seca)</h4>
|
||||||
|
<p className="text-xs text-text-muted">DFT - Dry Film Thickness</p>
|
||||||
|
<p className="text-sm text-text-secondary mt-2">
|
||||||
|
Medida após cura com medidor magnético ou eletrônico.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 bg-gradient-to-br from-primary/10 to-primary/5 rounded-2xl border border-primary/30">
|
||||||
|
<h4 className="font-bold text-primary mb-4">Relação EPU → EPS</h4>
|
||||||
|
<code className="block text-sm bg-surface p-4 rounded-xl text-center font-mono">
|
||||||
|
EPS (μm) = EPU (μm) × [SV (%) × (100 - Diluição %)] / 10000
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-text-muted mt-4">
|
||||||
|
Exemplo: EPU=150μm, SV=82%, Diluição=20% → EPS = 150 × (82 × 80) / 10000 = <strong>98 μm</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'environment',
|
||||||
|
title: 'Condições Ambientais',
|
||||||
|
icon: Thermometer,
|
||||||
|
keywords: ['temperatura', 'umidade', 'orvalho', 'ambiente', 'clima', 'condensação'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">Variáveis Críticas</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-red-500/10 rounded-xl border border-red-500/30">
|
||||||
|
<h4 className="font-bold text-red-600 mb-2">🌡️ Temperatura do Ar</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Ideal: <strong>16°C a 30°C</strong>. Abaixo de 16°C a cura é muito lenta.
|
||||||
|
Acima de 30°C ocorre "spray seco".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
|
||||||
|
<h4 className="font-bold text-blue-600 mb-2">💧 Umidade Relativa</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Ideal: <strong>30% a 60%</strong>. Nunca acima de 80-85%.
|
||||||
|
Alta umidade pode causar condensação e empolamento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
|
||||||
|
<h4 className="font-bold text-amber-600 mb-2">🌫️ Ponto de Orvalho</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
A temperatura da superfície deve estar <strong>no mínimo 3°C acima</strong> do ponto de orvalho
|
||||||
|
(ISO 8502-4, SSPC).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inspection',
|
||||||
|
title: 'Inspeção e Controle',
|
||||||
|
icon: CheckSquare,
|
||||||
|
keywords: ['inspeção', 'controle', 'qualidade', 'aderência', 'medição', 'teste', 'defeitos'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">Controle de Qualidade</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">Medição de Espessura (ASTM D7091)</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Mínimo de <strong>15 medições</strong> por área de ~5m².
|
||||||
|
Resultado deve estar entre 80% e 120% do especificado.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">Testes de Aderência</h4>
|
||||||
|
<ul className="text-sm text-text-secondary space-y-1">
|
||||||
|
<li><strong>Pull-Off (ASTM D4541):</strong> Força de tração em MPa</li>
|
||||||
|
<li><strong>Corte em X (ASTM D3359):</strong> Avaliação visual 0B-5B</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-red-500/10 rounded-xl border border-red-500/30">
|
||||||
|
<h4 className="font-bold text-red-600 mb-2">Defeitos Comuns (ISO 4628)</h4>
|
||||||
|
<ul className="text-sm text-text-secondary space-y-1">
|
||||||
|
<li>• Bolhas (blistering) - ar ou solventes aprisionados</li>
|
||||||
|
<li>• Crateras (pinholes) - escape de ar durante cura</li>
|
||||||
|
<li>• Descamação (peeling) - aderência inadequada</li>
|
||||||
|
<li>• Empolamento osmótico - sais contaminantes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'standards',
|
||||||
|
title: 'Normas Técnicas',
|
||||||
|
icon: Shield,
|
||||||
|
keywords: ['norma', 'abnt', 'iso', 'sspc', 'nace', 'petrobras', 'padrão', 'regulamento'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">Normas Aplicáveis</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-green-500/10 rounded-xl border border-green-500/30">
|
||||||
|
<h4 className="font-bold text-green-600 mb-2">ABNT (Brasil)</h4>
|
||||||
|
<ul className="text-xs text-text-secondary space-y-1">
|
||||||
|
<li>NBR 10443 - Espessura de película seca</li>
|
||||||
|
<li>NBR 11003 - Aderência por corte</li>
|
||||||
|
<li>NBR 14847 - Inspeção de pintura</li>
|
||||||
|
<li>NBR 15158 - Limpeza química</li>
|
||||||
|
<li>NBR 16267 - Granulometria de abrasivos</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-500/10 rounded-xl border border-blue-500/30">
|
||||||
|
<h4 className="font-bold text-blue-600 mb-2">ISO (Internacional)</h4>
|
||||||
|
<ul className="text-xs text-text-secondary space-y-1">
|
||||||
|
<li>ISO 12944 - Série completa de pintura anticorrosiva</li>
|
||||||
|
<li>ISO 8501 - Padrões visuais de limpeza</li>
|
||||||
|
<li>ISO 8502 - Contaminação superficial</li>
|
||||||
|
<li>ISO 8503 - Rugosidade de superfície</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-amber-500/10 rounded-xl border border-amber-500/30">
|
||||||
|
<h4 className="font-bold text-amber-600 mb-2">SSPC / NACE / Petrobras</h4>
|
||||||
|
<ul className="text-xs text-text-secondary space-y-1">
|
||||||
|
<li>SSPC-SP 6/10/11 - Graus de limpeza</li>
|
||||||
|
<li>SSPC-PA 2 - Medição de espessura</li>
|
||||||
|
<li>PETROBRAS N-9 - Jato abrasivo</li>
|
||||||
|
<li>PETROBRAS N-13 - Requisitos de pintura</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'navigation',
|
||||||
|
title: 'Navegação do App',
|
||||||
|
icon: BookOpen,
|
||||||
|
keywords: ['navegação', 'menu', 'tela', 'módulo', 'como usar', 'tutorial'],
|
||||||
|
content: (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xl font-black text-primary">Como Usar o GPI</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">1. Obras & Projetos</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Ponto de partida. Crie projetos com nome, cliente, datas e ambiente de corrosividade (C1-C5).
|
||||||
|
Todos os outros módulos se relacionam com as obras cadastradas aqui.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">2. Biblioteca Técnica</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Cadastre as fichas técnicas das tintas (PDF). O sistema extrai automaticamente dados como
|
||||||
|
Sólidos por Volume, rendimento e DFT de referência.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">3. Esquemas de Pintura</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Defina sistemas de pintura vinculando tintas da biblioteca em camadas (primer, intermediária, acabamento).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40">
|
||||||
|
<h4 className="font-bold text-text-main mb-2">4. Estudo de Rendimento</h4>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Calcule o consumo de tinta baseado em peso (toneladas) ou área (m²).
|
||||||
|
Selecione a tinta da biblioteca para usar automaticamente os dados técnicos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// Filtrar seções pela pesquisa
|
||||||
|
const filteredSections = useMemo(() => {
|
||||||
|
if (!searchTerm.trim()) return sections;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return sections.filter(s =>
|
||||||
|
s.title.toLowerCase().includes(term) ||
|
||||||
|
s.keywords.some(k => k.includes(term))
|
||||||
|
);
|
||||||
|
}, [sections, searchTerm]);
|
||||||
|
|
||||||
|
const activeContent = sections.find(s => s.id === activeSection);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-[100] flex items-center justify-center p-4 animate-in fade-in duration-300">
|
||||||
|
<div className="bg-surface rounded-3xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col border border-border/50 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-border/40 bg-gradient-to-r from-primary/10 to-transparent flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-primary flex items-center justify-center text-white shadow-lg shadow-primary/30">
|
||||||
|
<BookOpen size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-black text-text-main">Manual Técnico</h2>
|
||||||
|
<p className="text-xs text-text-muted">Pintura Industrial & Navegação do App</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Botão PDF */}
|
||||||
|
<a
|
||||||
|
href="/Engenharia_da_Durabilidade_Industrial.pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-red-500/10 text-red-500 rounded-xl text-sm font-bold hover:bg-red-500/20 transition-all border border-red-500/30"
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
<span className="hidden sm:inline">PDF Durabilidade</span>
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Botão Índice */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowIndex(!showIndex)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold transition-all border ${showIndex
|
||||||
|
? 'bg-primary text-white border-primary'
|
||||||
|
: 'bg-surface-soft text-text-main border-border/40 hover:border-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showIndex ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
Índice
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2.5 text-text-muted hover:text-text-main hover:bg-surface-soft rounded-xl transition-all"
|
||||||
|
aria-label="Fechar"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="p-4 border-b border-border/40 shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar no manual... (ex: sólidos, rendimento, espessura)"
|
||||||
|
className="w-full h-12 bg-surface-soft border border-border/40 rounded-xl pl-12 pr-4 text-sm focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium outline-none"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Sidebar / Index */}
|
||||||
|
<div className={`${showIndex ? 'w-72' : 'w-0'} shrink-0 border-r border-border/40 overflow-hidden transition-all duration-300`}>
|
||||||
|
<div className="p-4 overflow-y-auto h-full custom-scrollbar">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredSections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSection(section.id);
|
||||||
|
if (window.innerWidth < 768) setShowIndex(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all text-left ${activeSection === section.id
|
||||||
|
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||||
|
: 'text-text-secondary hover:bg-surface-hover hover:text-text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<section.icon size={18} />
|
||||||
|
<span className="truncate">{section.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredSections.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-text-muted text-sm">
|
||||||
|
Nenhum resultado para "{searchTerm}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||||
|
{activeContent ? (
|
||||||
|
<div className="max-w-3xl mx-auto animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-border/40">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<activeContent.icon size={20} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black text-text-main">{activeContent.title}</h2>
|
||||||
|
</div>
|
||||||
|
{activeContent.content}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-text-muted">
|
||||||
|
Selecione um tópico no índice
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-border/40 bg-surface-soft/50 text-center shrink-0">
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
GPI v2.1.0 • Manual Técnico baseado em normas ABNT, ISO, SSPC e Petrobras
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
src/client/components/Toast.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { setGlobalToastHandler } from '../utils/toastHandler';
|
||||||
|
import { ToastContext } from '../context/ToastContext';
|
||||||
|
import type { Toast } from '../context/ToastContext';
|
||||||
|
|
||||||
|
interface ToastProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: Toast['type'] = 'warning') => {
|
||||||
|
const id = Date.now().toString();
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
|
|
||||||
|
// Auto-remove after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showGuestWarning = useCallback(() => {
|
||||||
|
showToast('Você é um convidado e não possui permissão para esta ação', 'warning');
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Register global handler
|
||||||
|
React.useEffect(() => {
|
||||||
|
setGlobalToastHandler(showGuestWarning);
|
||||||
|
return () => setGlobalToastHandler(() => { });
|
||||||
|
}, [showGuestWarning]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast, showGuestWarning }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Toast Container - Centered */}
|
||||||
|
{toasts.length > 0 && (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-[100]">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
className="pointer-events-auto animate-in fade-in zoom-in duration-200 bg-gradient-to-r from-amber-500/95 to-orange-500/95 text-white px-8 py-5 rounded-2xl shadow-2xl shadow-amber-500/30 flex flex-col items-center justify-center text-center max-w-sm backdrop-blur-sm cursor-pointer hover:scale-[1.02] transition-transform"
|
||||||
|
>
|
||||||
|
<div className="bg-white/20 p-3 rounded-xl mb-3">
|
||||||
|
<ShieldAlert size={28} />
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-base mb-1">Acesso Restrito</p>
|
||||||
|
<p className="text-white/90 text-base leading-relaxed">{toast.message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
208
src/client/components/UnreadMessagesModal.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { X, Mail, Check, Trash2 } from 'lucide-react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
interface UnreadMessage {
|
||||||
|
_id: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
fromUser: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnreadMessagesModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnreadMessagesModal: React.FC<UnreadMessagesModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [messages, setMessages] = useState<UnreadMessage[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [confirmActionId, setConfirmActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchMessages();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get<UnreadMessage[]>('/messages/unread');
|
||||||
|
setMessages(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching messages:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAsRead = async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/messages/${messageId}/read`);
|
||||||
|
removeMessage(messageId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking message as read:', error);
|
||||||
|
alert('Erro ao marcar mensagem como lida');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const archiveMessage = async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/messages/${messageId}/archive`);
|
||||||
|
removeMessage(messageId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving message:', error);
|
||||||
|
alert('Erro ao arquivar mensagem');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMessage = async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/messages/${messageId}/recipient`);
|
||||||
|
removeMessage(messageId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting message:', error);
|
||||||
|
alert('Erro ao excluir mensagem');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMessage = (messageId: string) => {
|
||||||
|
setMessages(prev => prev.filter(m => m._id !== messageId));
|
||||||
|
if (messages.length === 1) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-surface rounded-2xl shadow-2xl max-w-lg w-full border border-border/40 animate-in slide-in-from-bottom-4 max-h-[80vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Mail className="text-primary" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-text-main">Mensagens Recebidas</h2>
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
{messages.length} {messages.length === 1 ? 'mensagem nova' : 'mensagens novas'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-surface-hover rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-text-muted" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-text-muted">Carregando mensagens...</p>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Mail size={48} className="text-text-muted/30 mx-auto mb-4" />
|
||||||
|
<p className="text-text-muted font-semibold">Nenhuma mensagem nova</p>
|
||||||
|
<p className="text-text-muted/60 text-sm mt-1">
|
||||||
|
Você está em dia com suas mensagens!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg._id}
|
||||||
|
className="bg-surface-soft border border-border/40 rounded-xl p-4 hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-text-main">{msg.fromUser.name}</div>
|
||||||
|
<div className="text-xs text-text-muted">{msg.fromUser.email}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
{new Date(msg.createdAt).toLocaleString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-surface rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-text-main text-sm leading-relaxed">{msg.message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center gap-2 relative">
|
||||||
|
<button
|
||||||
|
onClick={() => markAsRead(msg._id)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg font-semibold text-sm transition-colors flex-1"
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
Lida
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmActionId(msg._id)}
|
||||||
|
className="p-2 bg-surface hover:bg-red-500/10 text-text-muted hover:text-red-500 rounded-lg border border-border/40 transition-all"
|
||||||
|
title="Apagar ou Arquivar"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Action Prompt */}
|
||||||
|
{confirmActionId === msg._id && (
|
||||||
|
<div className="absolute inset-0 bg-surface border border-primary/20 rounded-lg shadow-xl flex flex-col items-center justify-center p-2 z-10 animate-in zoom-in-95 duration-200">
|
||||||
|
<p className="text-[10px] font-bold text-text-main mb-2">Deseja apagar ou arquivar?</p>
|
||||||
|
<div className="flex gap-2 w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => archiveMessage(msg._id)}
|
||||||
|
className="flex-1 py-1 bg-surface-hover hover:bg-border/40 text-[10px] font-bold rounded border border-border/40"
|
||||||
|
>
|
||||||
|
Arquivar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMessage(msg._id)}
|
||||||
|
className="flex-1 py-1 bg-red-600 text-white text-[10px] font-bold rounded"
|
||||||
|
>
|
||||||
|
Apagar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmActionId(null)}
|
||||||
|
className="mt-1 text-[8px] text-text-muted hover:underline"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<div className="p-6 border-t border-border/40 bg-surface-soft/50">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full px-4 py-2 bg-surface-hover hover:bg-border/40 rounded-lg font-semibold text-text-main transition-colors"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
322
src/client/components/admin/BackupRestore.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
src/client/components/admin/GeometrySettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
225
src/client/components/forms/PaintingInspectionForm.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AdhesionGradeSelect } from '../AdhesionGradeSelect';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { Droplets, Thermometer, Sun } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PaintingFormData {
|
||||||
|
epsPoints: string[];
|
||||||
|
adhesionTest: string;
|
||||||
|
batch?: string;
|
||||||
|
treatmentExecutor?: string;
|
||||||
|
stockItemId?: string;
|
||||||
|
temperature?: string;
|
||||||
|
relativeHumidity?: string;
|
||||||
|
period?: string;
|
||||||
|
partTemperature?: string;
|
||||||
|
treatmentType?: string;
|
||||||
|
roughnessReadings?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaintingInspectionFormProps {
|
||||||
|
formData: PaintingFormData;
|
||||||
|
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
|
||||||
|
handleEpsChange: (index: number, value: string) => void;
|
||||||
|
numericPoints: number[];
|
||||||
|
stockItems?: any[]; // Using any because StockItem might need to be imported or loosely typed here
|
||||||
|
handleRoughnessChange: (index: number, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaintingInspectionForm: React.FC<PaintingInspectionFormProps> = ({
|
||||||
|
formData,
|
||||||
|
handleChange,
|
||||||
|
handleEpsChange,
|
||||||
|
numericPoints,
|
||||||
|
stockItems = [],
|
||||||
|
handleRoughnessChange
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const minEps = numericPoints.length > 0 ? Math.min(...numericPoints) : 0;
|
||||||
|
const maxEps = numericPoints.length > 0 ? Math.max(...numericPoints) : 0;
|
||||||
|
const avgEps = numericPoints.length > 0 ? numericPoints.reduce((a, b) => a + b, 0) / numericPoints.length : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-500">
|
||||||
|
{/* Batch and Executor Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Tinta Utilizada</label>
|
||||||
|
<select
|
||||||
|
name="stockItemId"
|
||||||
|
value={formData.stockItemId || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-3 bg-surface border border-border/40 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">Selecione a tinta...</option>
|
||||||
|
{stockItems.map((item: any) => (
|
||||||
|
<option key={item.id || item._id} value={item.id || item._id}>
|
||||||
|
{item.dataSheetId?.name || 'Item sem nome'} - Lote: {item.batchNumber}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Lote / Referência</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="batch"
|
||||||
|
value={formData.batch || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-3 bg-surface border border-border/40 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
|
||||||
|
placeholder="Ex: Lote 123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Executante</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="treatmentExecutor"
|
||||||
|
value={formData.treatmentExecutor || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-3 bg-surface border border-border/40 rounded-xl focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
|
||||||
|
placeholder="Nome do pintor/equipe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border/30 rounded-2xl p-4 bg-amber-50/40 shadow-sm relative overflow-hidden group">
|
||||||
|
<div className="absolute top-0 left-0 w-1 h-full bg-primary opacity-60"></div>
|
||||||
|
<label className="text-[10px] font-black text-primary uppercase tracking-[0.2em] block mb-4">Medições de EPS (µm) • Mínimo 10</label>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||||
|
{formData.epsPoints.map((point: string, index: number) => (
|
||||||
|
<div key={index} className="flex flex-col">
|
||||||
|
<span className="text-[10px] text-text-muted mb-0.5 ml-1">{index + 1}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
className="w-full p-1.5 text-xs rounded border border-border bg-[var(--input-bg)] text-[var(--input-text)] focus:ring-1 focus:ring-primary outline-none transition-all"
|
||||||
|
value={point}
|
||||||
|
onChange={(e) => handleEpsChange(index, e.target.value)}
|
||||||
|
placeholder="µm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-3 gap-3 pt-4 border-t border-amber-200/20">
|
||||||
|
<div className="flex flex-col bg-white p-3 rounded-xl border border-amber-100 shadow-sm transition-transform hover:scale-[1.02]">
|
||||||
|
<span className="text-[9px] uppercase font-black text-primary tracking-widest">Mínimo</span>
|
||||||
|
<span className="text-base font-black text-stone-900">{minEps.toFixed(0)} <small className="text-[10px] text-stone-400 font-medium">µm</small></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col bg-white p-3 rounded-xl border border-amber-100 shadow-sm transition-transform hover:scale-[1.02]">
|
||||||
|
<span className="text-[9px] uppercase font-black text-primary tracking-widest">Máximo</span>
|
||||||
|
<span className="text-base font-black text-stone-900">{maxEps.toFixed(0)} <small className="text-[10px] text-stone-400 font-medium">µm</small></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col bg-white p-3 rounded-xl border border-primary/30 shadow-sm transition-transform hover:scale-[1.02] bg-gradient-to-br from-white to-amber-50/50">
|
||||||
|
<span className="text-[9px] uppercase font-black text-primary tracking-widest">Média Geral</span>
|
||||||
|
<span className="text-base font-black text-primary">{avgEps.toFixed(1)} <small className="text-[10px] opacity-70 font-medium">µm</small></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border/10 pt-4">
|
||||||
|
<Select
|
||||||
|
name="treatmentType"
|
||||||
|
label="Tipo de Tratamento / Jato"
|
||||||
|
value={formData.treatmentType || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: '-- Selecione --', value: '' },
|
||||||
|
{ label: 'Jateamento Abrasivo Seco', value: 'dry_abrasive_blasting' },
|
||||||
|
{ label: 'Hidrojateamento', value: 'water_jetting' },
|
||||||
|
{ label: 'Limpeza Mecânica (St2/St3)', value: 'mechanical_cleaning' },
|
||||||
|
{ label: 'Outro', value: 'other' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[10px] font-bold text-text-muted uppercase tracking-wider ml-1">Rugosidade Média (Opcional)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
type="number"
|
||||||
|
className="w-full p-2 text-xs border border-border/40 rounded-lg bg-surface text-center font-bold"
|
||||||
|
placeholder={`R${i + 1}`}
|
||||||
|
value={formData.roughnessReadings?.[i] || ''}
|
||||||
|
onChange={(e) => handleRoughnessChange(i, e.target.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environmental Conditions */}
|
||||||
|
<div className="bg-surface-soft border border-border/40 rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] block mb-3">Condições Ambientais</span>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Thermometer className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
name="temperature"
|
||||||
|
label="Temperatura (°C)"
|
||||||
|
type="number"
|
||||||
|
value={formData.temperature || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Droplets className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
name="relativeHumidity"
|
||||||
|
label="Umidade Relativa (%)"
|
||||||
|
type="number"
|
||||||
|
value={formData.relativeHumidity || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Sun className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
|
||||||
|
<Select
|
||||||
|
name="period"
|
||||||
|
label="Período"
|
||||||
|
value={formData.period || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: 'Manhã', value: 'morning' },
|
||||||
|
{ label: 'Tarde', value: 'afternoon' },
|
||||||
|
{ label: 'Noite', value: 'night' }
|
||||||
|
]}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border/10 pt-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Thermometer className="absolute left-3 top-1/2 -translate-y-1/2 text-primary/60 w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
name="partTemperature"
|
||||||
|
label="Temperatura das Peças (°C)"
|
||||||
|
type="number"
|
||||||
|
value={formData.partTemperature || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="pl-10 border-primary/20 bg-primary/5 focus:border-primary"
|
||||||
|
placeholder="Ex: 25.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AdhesionGradeSelect
|
||||||
|
name="adhesionTest"
|
||||||
|
label="Teste de Aderência"
|
||||||
|
value={formData.adhesionTest || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{numericPoints.length < 10 && numericPoints.length > 0 && (
|
||||||
|
<p className="text-[10px] text-error text-right italic font-medium">Preencha ao menos 10 medições para registrar.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
src/client/components/forms/SurfaceTreatmentForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { Droplets, Thermometer, Sun } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SurfaceTreatmentFormProps {
|
||||||
|
formData: any;
|
||||||
|
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => void;
|
||||||
|
handleReadingChange: (index: number, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SurfaceTreatmentForm: React.FC<SurfaceTreatmentFormProps> = ({ formData, handleChange, handleReadingChange }) => {
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const readings = (formData.roughnessReadings || []).map((r: any) => parseFloat(r)).filter((r: number) => !isNaN(r));
|
||||||
|
const minR = readings.length > 0 ? Math.min(...readings) : 0;
|
||||||
|
const maxR = readings.length > 0 ? Math.max(...readings) : 0;
|
||||||
|
const avgR = readings.length > 0 ? readings.reduce((a: number, b: number) => a + b, 0) / readings.length : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
|
{/* Header Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input name="batch" label="Lote / Referência" value={formData.batch || ''} onChange={handleChange} required placeholder="Ex: Lote A-123" />
|
||||||
|
<Input name="treatmentExecutor" label="Executante" value={formData.treatmentExecutor || ''} onChange={handleChange} required placeholder="Nome do Jatista/Empresa" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Treatment Details */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Select
|
||||||
|
name="treatmentType"
|
||||||
|
label="Tipo de Tratamento"
|
||||||
|
value={formData.treatmentType || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: 'Jateamento Abrasivo Seco', value: 'dry_abrasive_blasting' },
|
||||||
|
{ label: 'Hidrojateamento', value: 'water_jetting' },
|
||||||
|
{ label: 'Limpeza Mecânica (St2/St3)', value: 'mechanical_cleaning' },
|
||||||
|
{ label: 'Limpeza Manual', value: 'manual_cleaning' },
|
||||||
|
{ label: 'Outro', value: 'other' }
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
name="cleaningDegree"
|
||||||
|
label="Grau de Limpeza"
|
||||||
|
value={formData.cleaningDegree || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: 'Sa 1 (Jato Ligeiro)', value: 'Sa 1' },
|
||||||
|
{ label: 'Sa 2 (Comercial)', value: 'Sa 2' },
|
||||||
|
{ label: 'Sa 2½ (Metal Quase Branco)', value: 'Sa 2.5' },
|
||||||
|
{ label: 'Sa 3 (Metal Branco)', value: 'Sa 3' },
|
||||||
|
{ label: 'St 2 (Manual)', value: 'St 2' },
|
||||||
|
{ label: 'St 3 (Mecânica)', value: 'St 3' },
|
||||||
|
{ label: 'WJ-1', value: 'WJ-1' },
|
||||||
|
{ label: 'WJ-2', value: 'WJ-2' },
|
||||||
|
{ label: 'WJ-3', value: 'WJ-3' },
|
||||||
|
{ label: 'WJ-4', value: 'WJ-4' }
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
name="flashRust"
|
||||||
|
label="Flash Rust"
|
||||||
|
value={formData.flashRust || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: 'Ausente', value: 'none' },
|
||||||
|
{ label: 'Leve (Grau L)', value: 'light' },
|
||||||
|
{ label: 'Médio (Grau M)', value: 'medium' },
|
||||||
|
{ label: 'Pesado (Grau H)', value: 'heavy' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environmental Conditions */}
|
||||||
|
<div className="bg-surface-soft border border-border/40 rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] block mb-3">Condições Ambientais</span>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Thermometer className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
name="temperature"
|
||||||
|
label="Temperatura (°C)"
|
||||||
|
type="number"
|
||||||
|
value={formData.temperature || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Droplets className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
name="relativeHumidity"
|
||||||
|
label="Umidade Relativa (%)"
|
||||||
|
type="number"
|
||||||
|
value={formData.relativeHumidity || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Sun className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted w-4 h-4" />
|
||||||
|
<Select
|
||||||
|
name="period"
|
||||||
|
label="Período"
|
||||||
|
value={formData.period || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: 'Manhã', value: 'morning' },
|
||||||
|
{ label: 'Tarde', value: 'afternoon' },
|
||||||
|
{ label: 'Noite', value: 'night' }
|
||||||
|
]}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roughness Readings */}
|
||||||
|
<div className="border border-border/30 rounded-2xl p-4 bg-surface-soft shadow-sm">
|
||||||
|
<label className="text-[10px] font-black text-text-main uppercase tracking-[0.2em] block mb-4">Medições de Rugosidade (µm) • 5 Leituras</label>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{formData.roughnessReadings.map((reading: string, index: number) => (
|
||||||
|
<div key={index} className="flex flex-col">
|
||||||
|
<span className="text-[10px] text-text-muted mb-0.5 ml-1 text-center">{index + 1}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full p-2 text-sm text-center rounded-lg border border-border bg-[var(--input-bg)] text-[var(--input-text)] focus:ring-1 focus:ring-primary outline-none transition-all font-bold"
|
||||||
|
value={reading}
|
||||||
|
onChange={(e) => handleReadingChange(index, e.target.value)}
|
||||||
|
placeholder="-"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-3 pt-3 border-t border-border/20">
|
||||||
|
<div className="flex flex-col items-center p-2 rounded-lg bg-surface border border-border/20">
|
||||||
|
<span className="text-[9px] uppercase font-bold text-text-muted">Mín</span>
|
||||||
|
<span className="text-sm font-black text-text-main">{minR.toFixed(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-2 rounded-lg bg-surface border border-border/20">
|
||||||
|
<span className="text-[9px] uppercase font-bold text-text-muted">Máx</span>
|
||||||
|
<span className="text-sm font-black text-text-main">{maxR.toFixed(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-2 rounded-lg bg-primary/10 border border-primary/20">
|
||||||
|
<span className="text-[9px] uppercase font-bold text-primary">Média</span>
|
||||||
|
<span className="text-sm font-black text-primary">{avgR.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
93
src/client/components/modals/CloneSchemeModal.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import type { Project, PaintingScheme } from '../../types';
|
||||||
|
|
||||||
|
interface CloneSchemeModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
schemeToClone?: PaintingScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloneSchemeModal: React.FC<CloneSchemeModalProps> = ({ isOpen, onClose, onSuccess, schemeToClone }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [targetProjectId, setTargetProjectId] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
api.get('/projects').then(res => {
|
||||||
|
// Filter out the project where the scheme currently resides
|
||||||
|
const validProjects = res.data.filter((p: Project) => p.id !== schemeToClone?.projectId);
|
||||||
|
setProjects(validProjects);
|
||||||
|
}).catch(err => console.error("Error loading projects", err));
|
||||||
|
setTargetProjectId('');
|
||||||
|
}
|
||||||
|
}, [isOpen, schemeToClone]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schemeToClone || !targetProjectId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { id, projectId, ...schemeData } = schemeToClone;
|
||||||
|
|
||||||
|
await api.post('/painting-schemes', {
|
||||||
|
...schemeData,
|
||||||
|
projectId: targetProjectId,
|
||||||
|
name: schemeData.name // Keep same name or add suffix? Usually same name is fine for new project.
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cloning scheme', error);
|
||||||
|
alert('Erro ao clonar esquema');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!schemeToClone) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Clonar Esquema de Pintura">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="bg-surface-soft p-4 rounded-lg text-sm text-text-secondary border border-border">
|
||||||
|
<p className="font-bold text-text-main mb-1">Esquema Original:</p>
|
||||||
|
<p>{schemeToClone.name}</p>
|
||||||
|
<p className="text-xs text-text-muted mt-1">{schemeToClone.type} • {schemeToClone.manufacturer}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
name="targetProject"
|
||||||
|
label="Copiar para a Obra/Projeto"
|
||||||
|
options={projects.map(p => ({ label: p.name, value: p.id }))}
|
||||||
|
value={targetProjectId}
|
||||||
|
onChange={(e) => setTargetProjectId(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={loading || !targetProjectId}>{loading ? 'Clonando...' : 'Confirmar Cópia'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
463
src/client/components/modals/CreateControlRecordModal.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { Trash2, Plus, Box, Calculator } from 'lucide-react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import { type ApplicationRecord, type Part, type Inspection } from '../../types';
|
||||||
|
|
||||||
|
interface CreateControlRecordModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
projectId: string;
|
||||||
|
initialData?: ApplicationRecord;
|
||||||
|
availableParts: Part[];
|
||||||
|
existingRecords?: ApplicationRecord[];
|
||||||
|
availableBatches?: Inspection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchItem {
|
||||||
|
partId: string;
|
||||||
|
quantity: number; // This is now WEIGHT in KG
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateControlRecordModal: React.FC<CreateControlRecordModalProps> = ({
|
||||||
|
isOpen, onClose, onSuccess, projectId, initialData, availableParts, existingRecords = [], availableBatches = []
|
||||||
|
}) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Batch Composition State
|
||||||
|
const [items, setItems] = useState<BatchItem[]>([]);
|
||||||
|
const [selectedPartId, setSelectedPartId] = useState('');
|
||||||
|
const [quantity, setQuantity] = useState('');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
coatStage: '',
|
||||||
|
pieceDescription: '',
|
||||||
|
date: '',
|
||||||
|
operator: '',
|
||||||
|
realWeight: '',
|
||||||
|
volumeUsed: '',
|
||||||
|
areaPainted: '',
|
||||||
|
wetThicknessAvg: '',
|
||||||
|
dryThicknessCalc: '',
|
||||||
|
method: '',
|
||||||
|
diluentUsed: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setFormData({
|
||||||
|
coatStage: initialData.coatStage || '',
|
||||||
|
pieceDescription: initialData.pieceDescription || '',
|
||||||
|
date: initialData.date ? new Date(initialData.date).toISOString().split('T')[0] : '',
|
||||||
|
operator: initialData.operator || '',
|
||||||
|
realWeight: initialData.realWeight?.toString() || '',
|
||||||
|
volumeUsed: initialData.volumeUsed?.toString() || '',
|
||||||
|
areaPainted: initialData.areaPainted?.toString() || '',
|
||||||
|
wetThicknessAvg: initialData.wetThicknessAvg?.toString() || '',
|
||||||
|
dryThicknessCalc: initialData.dryThicknessCalc?.toString() || '',
|
||||||
|
method: initialData.method || '',
|
||||||
|
diluentUsed: initialData.diluentUsed?.toString() || '',
|
||||||
|
notes: initialData.notes || ''
|
||||||
|
});
|
||||||
|
// Use existing items if available
|
||||||
|
setItems(initialData.items || []);
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
coatStage: '',
|
||||||
|
pieceDescription: '',
|
||||||
|
date: '',
|
||||||
|
operator: '',
|
||||||
|
realWeight: '',
|
||||||
|
volumeUsed: '',
|
||||||
|
areaPainted: '',
|
||||||
|
wetThicknessAvg: '',
|
||||||
|
dryThicknessCalc: '',
|
||||||
|
method: '',
|
||||||
|
diluentUsed: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
|
setSelectedPartId('');
|
||||||
|
setQuantity('');
|
||||||
|
}, [initialData, isOpen]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPartBalance = (partId: string): number => {
|
||||||
|
const part = availableParts.find(p => p.id === partId);
|
||||||
|
if (!part) return 0;
|
||||||
|
|
||||||
|
// Total estimated weight of the part/lot
|
||||||
|
const totalEstimatedWeight = part.weight || 0;
|
||||||
|
|
||||||
|
// Sum of weight already used in OTHER records (exclude current record if editing)
|
||||||
|
let usedWeight = 0;
|
||||||
|
existingRecords.forEach(record => {
|
||||||
|
// If we are editing, ignore the current record's usage from the "existing" sum
|
||||||
|
// so we don't double count or block the user from keeping the same value.
|
||||||
|
if (initialData && record.id === initialData.id) return;
|
||||||
|
|
||||||
|
const recordItems = record.items || [];
|
||||||
|
recordItems.forEach(item => {
|
||||||
|
if (item.partId === partId) {
|
||||||
|
usedWeight += item.quantity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also subtract weight currently in the staging list (items) BUT exclude the one we might be adding/editing?
|
||||||
|
// Actually, `items` state reflects the *current* session.
|
||||||
|
// If we add multiple chunks of the same part in one session (unlikely but possible), we should sum them up.
|
||||||
|
// For simple validation of "Next Add", we check: (Used + CurrentItems + NewAmount) <= Total
|
||||||
|
|
||||||
|
const currentSessionWeight = items
|
||||||
|
.filter(i => i.partId === partId)
|
||||||
|
.reduce((sum, i) => sum + i.quantity, 0);
|
||||||
|
|
||||||
|
return totalEstimatedWeight - (usedWeight + currentSessionWeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
if (!selectedPartId || !quantity || Number(quantity) <= 0) return;
|
||||||
|
|
||||||
|
const weightToAdd = Number(quantity);
|
||||||
|
const part = availableParts.find(p => p.id === selectedPartId);
|
||||||
|
|
||||||
|
if (!part) return;
|
||||||
|
|
||||||
|
// Validation Logic
|
||||||
|
const balance = getPartBalance(selectedPartId);
|
||||||
|
|
||||||
|
// Allow a small margin of error (e.g. 1%) or strict?
|
||||||
|
// User requested: "se extrapolar ... o sistema nao aceita"
|
||||||
|
if (weightToAdd > balance) {
|
||||||
|
alert(`Quantidade excede o saldo disponível para esta peça.\n\nEstimado Total: ${part.weight} kg\nSaldo Disponível: ${balance.toFixed(2)} kg`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem = { partId: selectedPartId, quantity: weightToAdd };
|
||||||
|
const newItems = [...items, newItem];
|
||||||
|
setItems(newItems);
|
||||||
|
|
||||||
|
// Auto-calc totals
|
||||||
|
updateTotals(newItems);
|
||||||
|
|
||||||
|
setSelectedPartId('');
|
||||||
|
setQuantity('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
const newItems = items.filter((_, i) => i !== index);
|
||||||
|
setItems(newItems);
|
||||||
|
updateTotals(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTotals = (currentItems: BatchItem[]) => {
|
||||||
|
let totalWeight = 0;
|
||||||
|
let totalArea = 0;
|
||||||
|
|
||||||
|
currentItems.forEach(item => {
|
||||||
|
const part = availableParts.find(p => p.id === item.partId);
|
||||||
|
if (part && part.weight && part.weight > 0) {
|
||||||
|
// Item quantity IS the weight now
|
||||||
|
const weightUsed = item.quantity;
|
||||||
|
totalWeight += weightUsed;
|
||||||
|
|
||||||
|
// Calculate area proportional to weight used based on part definition
|
||||||
|
// Area Ratio = Total Area / Total Weight
|
||||||
|
// Used Area = Weight Used * Ratio
|
||||||
|
const areaRatio = (part.area || 0) / part.weight;
|
||||||
|
totalArea += weightUsed * areaRatio;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
realWeight: totalWeight > 0 ? totalWeight.toFixed(1) : prev.realWeight,
|
||||||
|
areaPainted: totalArea > 0 ? totalArea.toFixed(1) : prev.areaPainted
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
projectId,
|
||||||
|
realWeight: parseFloat(formData.realWeight),
|
||||||
|
volumeUsed: parseFloat(formData.volumeUsed),
|
||||||
|
areaPainted: parseFloat(formData.areaPainted),
|
||||||
|
wetThicknessAvg: parseFloat(formData.wetThicknessAvg),
|
||||||
|
dryThicknessCalc: parseFloat(formData.dryThicknessCalc),
|
||||||
|
diluentUsed: parseFloat(formData.diluentUsed),
|
||||||
|
items: items.map(i => ({ partId: i.partId, quantity: Number(i.quantity) }))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
await api.put(`/application-records/${initialData.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/application-records', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving record', error);
|
||||||
|
alert('Erro ao salvar registro');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Lote de Pintura" : "Novo Lote de Pintura"}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|
||||||
|
{/* Batch Identification */}
|
||||||
|
<div className="p-4 bg-surface-soft rounded-xl border border-border/40 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-border/20">
|
||||||
|
<Box className="w-4 h-4 text-primary" />
|
||||||
|
<span className="text-xs font-bold text-text-muted uppercase tracking-widest">Identificação do Lote</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableBatches && availableBatches.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
name="batchSelect"
|
||||||
|
label="Selecionar Lote (Inspeção)"
|
||||||
|
options={[
|
||||||
|
{ label: 'Selecione um lote...', value: '' },
|
||||||
|
...availableBatches.map(b => ({ label: (b.batch || '').split('(')[0].trim(), value: b.id })),
|
||||||
|
{ label: 'Outro / Manual', value: 'manual' }
|
||||||
|
]}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === 'manual' || val === '') {
|
||||||
|
setFormData(prev => ({ ...prev, pieceDescription: '' }));
|
||||||
|
} else {
|
||||||
|
const selectedBatch = availableBatches.find(b => b.id === val);
|
||||||
|
if (selectedBatch) {
|
||||||
|
// Extract "Lote-XX" from "Lote-XX (date...)" if needed, or just use label
|
||||||
|
// Calculate Avg EPS
|
||||||
|
let avgEPS = '';
|
||||||
|
if (selectedBatch.epsPoints && selectedBatch.epsPoints.length > 0) {
|
||||||
|
// epsPoints are (number | null)[]
|
||||||
|
const validPoints = selectedBatch.epsPoints.filter(p => p !== null && p > 0) as number[];
|
||||||
|
if (validPoints.length > 0) {
|
||||||
|
const sum = validPoints.reduce((a, b) => a + b, 0);
|
||||||
|
avgEPS = (sum / validPoints.length).toFixed(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
pieceDescription: selectedBatch.batch || '',
|
||||||
|
dryThicknessCalc: avgEPS
|
||||||
|
// wetThicknessAvg: ... Need solidsVolume etc.
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={availableBatches.find(b => b.batch === formData.pieceDescription)?.id || (formData.pieceDescription ? 'manual' : '')}
|
||||||
|
/>
|
||||||
|
{((!availableBatches.some(b => b.batch === formData.pieceDescription) && formData.pieceDescription !== '') || (availableBatches.find(b => b.batch === formData.pieceDescription)?.id === undefined && formData.pieceDescription === '') || formData.pieceDescription === '') && (
|
||||||
|
<div className={availableBatches.some(b => b.batch === formData.pieceDescription) ? 'hidden' : ''}>
|
||||||
|
<Input
|
||||||
|
name="pieceDescription"
|
||||||
|
placeholder="Ou digite manualmente..."
|
||||||
|
value={formData.pieceDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
name="pieceDescription"
|
||||||
|
label="Nome/Número do Lote"
|
||||||
|
placeholder="Ex: Lote 01/2024 - Estrutura Principal"
|
||||||
|
value={formData.pieceDescription}
|
||||||
|
onChange={handleChange}
|
||||||
|
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
name="coatStage"
|
||||||
|
label="Demão Aplicada"
|
||||||
|
options={[
|
||||||
|
{ label: 'Primer / Fundo', value: 'primer' },
|
||||||
|
{ label: 'Intermediária', value: 'intermediate' },
|
||||||
|
{ label: 'Acabamento', value: 'finish' },
|
||||||
|
{ label: 'Stripe Coat', value: 'stripe_coat' }
|
||||||
|
]}
|
||||||
|
value={formData.coatStage}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<Input name="date" label="Data de Aplicação" type="date" value={formData.date} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch Composition */}
|
||||||
|
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calculator className="w-4 h-4 text-primary" />
|
||||||
|
<span className="text-xs font-bold text-text-muted uppercase tracking-widest">Composição do Lote</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-text-muted bg-white/50 px-2 py-1 rounded-md">
|
||||||
|
{items.length} itens adicionados
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_80px_auto] gap-2 items-end">
|
||||||
|
<Select
|
||||||
|
name="partSelector"
|
||||||
|
label="Adicionar Peça / Geometria"
|
||||||
|
options={availableParts.map(p => {
|
||||||
|
// Calculate balance for display if needed, but keeping it simple for select
|
||||||
|
return {
|
||||||
|
label: `${p.description} (Total: ${p.weight}kg)`,
|
||||||
|
value: p.id
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
value={selectedPartId}
|
||||||
|
onChange={(e) => setSelectedPartId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="qty"
|
||||||
|
label="Qtd (Kg)"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
step="0.1"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={addItem} size="sm" className="h-10 w-10 mb-0.5 px-0" title="Adicionar Item" aria-label="Adicionar Item">
|
||||||
|
<Plus size={18} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items List */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="bg-surface rounded-lg border border-border/40 overflow-hidden max-h-40 overflow-y-auto">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="bg-surface-soft text-text-muted font-bold uppercase sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Peça</th>
|
||||||
|
<th className="px-3 py-2 text-center">Peso Lançado</th>
|
||||||
|
<th className="px-3 py-2 text-right">Área Calc.</th>
|
||||||
|
<th className="w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/20">
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
const part = availableParts.find(p => p.id === item.partId);
|
||||||
|
if (!part) return null;
|
||||||
|
|
||||||
|
const areaRatio = (part.weight && part.weight > 0) ? ((part.area || 0) / part.weight) : 0;
|
||||||
|
const areaCalculated = item.quantity * areaRatio;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={idx} className="hover:bg-surface-hover/50">
|
||||||
|
<td className="px-3 py-2 truncate max-w-[150px]">{part.description}</td>
|
||||||
|
<td className="px-3 py-2 text-center font-bold text-primary">{item.quantity.toFixed(1)} kg</td>
|
||||||
|
<td className="px-3 py-2 text-right text-text-muted">
|
||||||
|
{areaCalculated.toFixed(2)} m²
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(idx)}
|
||||||
|
className="text-text-muted hover:text-error transition-colors"
|
||||||
|
title="Remover Item"
|
||||||
|
aria-label="Remover Item"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calculated Results / Manual Override */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Input name="areaPainted" label="Área Total (m²)" type="number" step="0.1" value={formData.areaPainted} onChange={handleChange} />
|
||||||
|
{items.length > 0 && <div className="absolute top-0 right-0 text-[9px] text-green-600 font-bold bg-green-100 px-1.5 py-0.5 rounded">Calculado</div>}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Input name="realWeight" label="Peso Total (kg)" type="number" step="0.1" value={formData.realWeight} onChange={handleChange} />
|
||||||
|
{items.length > 0 && <div className="absolute top-0 right-0 text-[9px] text-green-600 font-bold bg-green-100 px-1.5 py-0.5 rounded">Calculado</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="volumeUsed" label="Volume de Tinta Gasto (L)" type="number" step="0.1" value={formData.volumeUsed} onChange={handleChange} />
|
||||||
|
<Input name="diluentUsed" label="Diluente (L)" type="number" step="0.1" value={formData.diluentUsed} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="operator" label="Pintor Responsável" value={formData.operator} onChange={handleChange} />
|
||||||
|
<Select
|
||||||
|
name="method"
|
||||||
|
label="Método de Aplicação"
|
||||||
|
options={[
|
||||||
|
{ label: 'Pistola Airless', value: 'airless' },
|
||||||
|
{ label: 'Pistola Convencional', value: 'conventional' },
|
||||||
|
{ label: 'Rolo / Trincha', value: 'roller' },
|
||||||
|
]}
|
||||||
|
value={formData.method}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="wetThicknessAvg" label="Esp. Úmida (μm)" type="number" value={formData.wetThicknessAvg} onChange={handleChange} />
|
||||||
|
<Input name="dryThicknessCalc" label="Esp. Seca Calc (μm)" type="number" value={formData.dryThicknessCalc} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
aria-label="Observações"
|
||||||
|
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Edição' : 'Criar Lote')}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
444
src/client/components/modals/CreateInspectionModal.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { PaintingInspectionForm } from '../forms/PaintingInspectionForm';
|
||||||
|
import { SurfaceTreatmentForm } from '../forms/SurfaceTreatmentForm';
|
||||||
|
import { PhotoUpload } from '../PhotoUpload';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import type { Inspection, ApplicationRecord } from '../../types';
|
||||||
|
import { Paintbrush, Hammer } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { stockService } from '../../services/stockService';
|
||||||
|
import type { IInstrument } from '../../types/Instrument';
|
||||||
|
|
||||||
|
interface CreateInspectionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
projectId?: string;
|
||||||
|
initialData?: Inspection;
|
||||||
|
availableBatches?: ApplicationRecord[];
|
||||||
|
existingInspections?: Inspection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InspectionFormData {
|
||||||
|
date: string;
|
||||||
|
inspector: string;
|
||||||
|
pieceDescription: string;
|
||||||
|
appearance: string;
|
||||||
|
defects: string;
|
||||||
|
photos: string[];
|
||||||
|
applicationRecordId: string;
|
||||||
|
partTemperature: string;
|
||||||
|
weightKg: string;
|
||||||
|
instrumentId: string;
|
||||||
|
|
||||||
|
// Painting
|
||||||
|
epsPoints: string[];
|
||||||
|
adhesionTest: string;
|
||||||
|
stockItemId: string;
|
||||||
|
|
||||||
|
// Surface Treatment
|
||||||
|
batch: string;
|
||||||
|
treatmentExecutor: string;
|
||||||
|
treatmentType: string;
|
||||||
|
cleaningDegree: string;
|
||||||
|
roughnessReadings: string[];
|
||||||
|
flashRust: string;
|
||||||
|
temperature: string;
|
||||||
|
relativeHumidity: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_BATCHES: ApplicationRecord[] = [];
|
||||||
|
const EMPTY_INSPECTIONS: Inspection[] = [];
|
||||||
|
|
||||||
|
export const CreateInspectionModal: React.FC<CreateInspectionModalProps> = ({
|
||||||
|
isOpen, onClose, onSuccess, projectId, initialData, availableBatches = EMPTY_BATCHES, existingInspections = EMPTY_INSPECTIONS
|
||||||
|
}) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [projects, setProjects] = useState<{ id: string, name: string }[]>([]);
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState(projectId || '');
|
||||||
|
const [stockItems, setStockItems] = useState<{ _id: string, rrNumber: string, batchNumber: string, quantity: number, unit: string }[]>([]);
|
||||||
|
const [instruments, setInstruments] = useState<IInstrument[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stockService.getAll().then(data => setStockItems(data));
|
||||||
|
api.get('/instruments').then(res => setInstruments(res.data.filter((i: IInstrument) => i.status === 'active')));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [type, setType] = useState<'painting' | 'surface_treatment'>('painting');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<InspectionFormData>({
|
||||||
|
date: '',
|
||||||
|
inspector: '',
|
||||||
|
pieceDescription: '',
|
||||||
|
appearance: '',
|
||||||
|
defects: '',
|
||||||
|
photos: [],
|
||||||
|
applicationRecordId: '',
|
||||||
|
partTemperature: '',
|
||||||
|
weightKg: '',
|
||||||
|
instrumentId: '',
|
||||||
|
|
||||||
|
// Painting
|
||||||
|
epsPoints: Array(20).fill(''),
|
||||||
|
adhesionTest: '',
|
||||||
|
stockItemId: '',
|
||||||
|
|
||||||
|
// Surface Treatment
|
||||||
|
batch: '',
|
||||||
|
treatmentExecutor: '',
|
||||||
|
treatmentType: '',
|
||||||
|
cleaningDegree: '',
|
||||||
|
roughnessReadings: Array(5).fill(''),
|
||||||
|
flashRust: '',
|
||||||
|
temperature: '',
|
||||||
|
relativeHumidity: '',
|
||||||
|
period: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
api.get('/projects').then(response => {
|
||||||
|
setProjects(response.data);
|
||||||
|
}).catch(err => console.error("Error loading projects", err));
|
||||||
|
} else {
|
||||||
|
setSelectedProjectId(projectId);
|
||||||
|
}
|
||||||
|
}, [projectId, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
const initialEps = Array(20).fill('');
|
||||||
|
if (initialData.epsPoints) {
|
||||||
|
initialData.epsPoints.forEach((p, i) => { if (i < 20) initialEps[i] = p?.toString() || ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialRoughness = Array(5).fill('');
|
||||||
|
if (initialData.roughnessReadings) {
|
||||||
|
initialData.roughnessReadings.forEach((p, i) => { if (i < 5) initialRoughness[i] = p?.toString() || ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
setType(initialData.type || 'painting');
|
||||||
|
setFormData({
|
||||||
|
date: initialData.date ? new Date(initialData.date).toISOString().split('T')[0] : '',
|
||||||
|
inspector: initialData.inspector || '',
|
||||||
|
pieceDescription: initialData.pieceDescription || '',
|
||||||
|
appearance: initialData.appearance || '',
|
||||||
|
defects: initialData.defects || '',
|
||||||
|
photos: initialData.photos || [],
|
||||||
|
applicationRecordId: initialData.applicationRecordId || '',
|
||||||
|
|
||||||
|
epsPoints: initialEps,
|
||||||
|
adhesionTest: initialData.adhesionTest || '',
|
||||||
|
stockItemId: typeof initialData.stockItemId === 'object' ? initialData.stockItemId._id : (initialData.stockItemId || ''),
|
||||||
|
|
||||||
|
batch: initialData.batch || '',
|
||||||
|
treatmentExecutor: initialData.treatmentExecutor || '',
|
||||||
|
treatmentType: initialData.treatmentType || '',
|
||||||
|
cleaningDegree: initialData.cleaningDegree || '',
|
||||||
|
roughnessReadings: initialRoughness,
|
||||||
|
flashRust: initialData.flashRust || '',
|
||||||
|
temperature: initialData.temperature?.toString() || '',
|
||||||
|
relativeHumidity: initialData.relativeHumidity?.toString() || '',
|
||||||
|
period: initialData.period || '',
|
||||||
|
partTemperature: initialData.partTemperature?.toString() || '',
|
||||||
|
weightKg: initialData.weightKg?.toString() || '',
|
||||||
|
instrumentId: typeof initialData.instrumentId === 'object' && initialData.instrumentId ? (initialData.instrumentId as IInstrument)._id : (initialData.instrumentId as string || '')
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
|
||||||
|
} else {
|
||||||
|
// Auto-calculate next batch number logic
|
||||||
|
let nextBatch = '';
|
||||||
|
if (existingInspections && existingInspections.length > 0) {
|
||||||
|
const nums = existingInspections
|
||||||
|
.map(i => {
|
||||||
|
const match = (i.batch || '').match(/Lote-(\d+)/i);
|
||||||
|
return match ? parseInt(match[1]) : 0;
|
||||||
|
})
|
||||||
|
.filter(n => n > 0);
|
||||||
|
|
||||||
|
const maxNum = nums.length > 0 ? Math.max(...nums) : 0;
|
||||||
|
nextBatch = `Lote-${String(maxNum + 1).padStart(2, '0')}`;
|
||||||
|
} else {
|
||||||
|
nextBatch = 'Lote-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setType('painting');
|
||||||
|
setFormData({
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
inspector: '',
|
||||||
|
pieceDescription: '',
|
||||||
|
appearance: '',
|
||||||
|
defects: '',
|
||||||
|
photos: [],
|
||||||
|
applicationRecordId: '',
|
||||||
|
epsPoints: Array(20).fill(''),
|
||||||
|
adhesionTest: '',
|
||||||
|
stockItemId: '',
|
||||||
|
batch: nextBatch, // Auto-filled
|
||||||
|
treatmentExecutor: '',
|
||||||
|
treatmentType: '',
|
||||||
|
cleaningDegree: '',
|
||||||
|
roughnessReadings: Array(5).fill(''),
|
||||||
|
flashRust: '',
|
||||||
|
temperature: '',
|
||||||
|
relativeHumidity: '',
|
||||||
|
period: '',
|
||||||
|
partTemperature: '',
|
||||||
|
weightKg: '',
|
||||||
|
instrumentId: ''
|
||||||
|
});
|
||||||
|
if (projectId) setSelectedProjectId(projectId);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [initialData, isOpen, projectId]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEpsChange = (index: number, value: string) => {
|
||||||
|
const newPoints = [...formData.epsPoints];
|
||||||
|
newPoints[index] = value;
|
||||||
|
setFormData({ ...formData, epsPoints: newPoints });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoughnessChange = (index: number, value: string) => {
|
||||||
|
const newPoints = [...formData.roughnessReadings];
|
||||||
|
newPoints[index] = value;
|
||||||
|
setFormData({ ...formData, roughnessReadings: newPoints });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoAdd = (url: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, photos: [...prev.photos, url] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoRemove = (index: number) => {
|
||||||
|
setFormData((prev) => ({ ...prev, photos: prev.photos.filter((_, i) => i !== index) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const numericEps = formData.epsPoints.map(p => parseFloat(p)).filter(p => !isNaN(p));
|
||||||
|
// const isValidPainting = type === 'painting' && numericEps.length >= 10; // Relaxed
|
||||||
|
// const isValidTreatment = type === 'surface_treatment' && formData.treatmentType && formData.cleaningDegree; // Relaxed
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
const canSubmit = formData.date && formData.inspector && formData.pieceDescription;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedProjectId) {
|
||||||
|
alert("Selecione um projeto");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
type,
|
||||||
|
date: formData.date,
|
||||||
|
inspector: formData.inspector,
|
||||||
|
pieceDescription: formData.pieceDescription,
|
||||||
|
appearance: formData.appearance,
|
||||||
|
defects: formData.defects,
|
||||||
|
photos: formData.photos,
|
||||||
|
applicationRecordId: formData.applicationRecordId || null,
|
||||||
|
|
||||||
|
// Fields are sent regardless, backend optionality handles it
|
||||||
|
epsPoints: formData.epsPoints.map(p => p !== '' ? parseFloat(p) : null),
|
||||||
|
adhesionTest: formData.adhesionTest,
|
||||||
|
stockItemId: formData.stockItemId || null,
|
||||||
|
|
||||||
|
batch: formData.batch,
|
||||||
|
treatmentExecutor: formData.treatmentExecutor,
|
||||||
|
treatmentType: formData.treatmentType,
|
||||||
|
cleaningDegree: formData.cleaningDegree,
|
||||||
|
roughnessReadings: formData.roughnessReadings.map(p => p !== '' ? parseFloat(p) : null),
|
||||||
|
flashRust: formData.flashRust,
|
||||||
|
temperature: formData.temperature ? parseFloat(formData.temperature) : null,
|
||||||
|
relativeHumidity: formData.relativeHumidity ? parseFloat(formData.relativeHumidity) : null,
|
||||||
|
period: formData.period,
|
||||||
|
partTemperature: formData.partTemperature ? parseFloat(formData.partTemperature) : null,
|
||||||
|
weightKg: formData.weightKg ? parseFloat(formData.weightKg) : null,
|
||||||
|
instrumentId: formData.instrumentId || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
await api.put(`/inspections/${initialData.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/inspections', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving inspection', error);
|
||||||
|
alert('Erro ao salvar inspeção');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Inspeção" : "Nova Inspeção"}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setType('surface_treatment')}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-bold rounded-lg transition-all",
|
||||||
|
type === 'surface_treatment'
|
||||||
|
? "bg-amber-600 text-white shadow-lg shadow-amber-600/20"
|
||||||
|
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Hammer size={16} />
|
||||||
|
Tratamento
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setType('painting')}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-bold rounded-lg transition-all",
|
||||||
|
type === 'painting'
|
||||||
|
? "bg-primary text-white shadow-lg shadow-primary/20"
|
||||||
|
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Paintbrush size={16} />
|
||||||
|
Pintura
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common Fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!projectId && (
|
||||||
|
<Select
|
||||||
|
name="projectId"
|
||||||
|
label="Projeto"
|
||||||
|
options={projects.map(p => ({ label: p.name, value: p.id }))}
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batch Selection (Painting Only) */}
|
||||||
|
{type === 'painting' && availableBatches.length > 0 && (
|
||||||
|
<Select
|
||||||
|
name="applicationRecordId"
|
||||||
|
label="Vincular ao Lote de Pintura (Opcional)"
|
||||||
|
options={[
|
||||||
|
{ label: 'Selecione um lote...', value: '' },
|
||||||
|
...availableBatches.map(b => ({
|
||||||
|
label: `${b.pieceDescription || 'Lote sem nome'} (${b.coatStage})`,
|
||||||
|
value: b.id
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
value={formData.applicationRecordId}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input name="date" label="Data da Inspeção" type="date" value={formData.date} onChange={handleChange} required />
|
||||||
|
<Input name="inspector" label="Inspetor" value={formData.inspector} onChange={handleChange} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input name="pieceDescription" label="Peça/Área Inspecionada" value={formData.pieceDescription} onChange={handleChange} required />
|
||||||
|
<Input name="weightKg" label="Peso Inspecionado (kg)" type="number" value={formData.weightKg} onChange={handleChange} required placeholder="Ex: 688" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
name="instrumentId"
|
||||||
|
label="Instrumento Utilizado"
|
||||||
|
options={[
|
||||||
|
{ label: 'Selecione um instrumento...', value: '' },
|
||||||
|
...instruments.map(i => ({ label: `${i.name} - ${i.serialNumber} (${i.type})`, value: i._id }))
|
||||||
|
]}
|
||||||
|
value={formData.instrumentId}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Specific Forms */}
|
||||||
|
{type === 'painting' ? (
|
||||||
|
<PaintingInspectionForm
|
||||||
|
formData={formData}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleEpsChange={handleEpsChange}
|
||||||
|
numericPoints={numericEps}
|
||||||
|
stockItems={stockItems}
|
||||||
|
handleRoughnessChange={handleRoughnessChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SurfaceTreatmentForm
|
||||||
|
formData={formData}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleReadingChange={handleRoughnessChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Common Footer (Photos, Observation, Status) */}
|
||||||
|
<div className="space-y-4 pt-4 border-t border-border/40">
|
||||||
|
<PhotoUpload
|
||||||
|
photos={formData.photos}
|
||||||
|
onPhotosChange={handlePhotoAdd}
|
||||||
|
onRemovePhoto={handlePhotoRemove}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Defeitos / Observações</label>
|
||||||
|
<textarea
|
||||||
|
name="defects"
|
||||||
|
className="flex min-h-[80px] w-full rounded-xl border border-border bg-[var(--input-bg)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all placeholder:text-text-muted/50"
|
||||||
|
value={formData.defects}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Descreva observações, falhas encontradas ou detalhes adicionais..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
name="appearance"
|
||||||
|
label="Resultado Final"
|
||||||
|
options={[
|
||||||
|
{ label: 'Aprovada', value: 'approved' },
|
||||||
|
{ label: 'Reprovada', value: 'rejected' },
|
||||||
|
{ label: 'Com Ressalvas', value: 'notes' }
|
||||||
|
]}
|
||||||
|
value={formData.appearance}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={loading || !canSubmit}>
|
||||||
|
{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Registrar Inspeção')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
374
src/client/components/modals/CreatePaintingSchemeModal.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import type { PaintingScheme, TechnicalDataSheet } from '../../types';
|
||||||
|
|
||||||
|
interface CreatePaintingSchemeModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
projectId?: string;
|
||||||
|
initialData?: PaintingScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps> = ({ isOpen, onClose, onSuccess, projectId, initialData }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [projects, setProjects] = React.useState<{ id: string, name: string }[]>([]);
|
||||||
|
const [dataSheets, setDataSheets] = React.useState<TechnicalDataSheet[]>([]);
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = React.useState(projectId || '');
|
||||||
|
|
||||||
|
const [formData, setFormData] = React.useState({
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
coat: '',
|
||||||
|
solidsVolume: '',
|
||||||
|
yieldTheoretical: '',
|
||||||
|
epsMin: '',
|
||||||
|
epsMax: '',
|
||||||
|
dilution: '',
|
||||||
|
manufacturer: '',
|
||||||
|
color: '',
|
||||||
|
notes: '',
|
||||||
|
paintConsumption: '',
|
||||||
|
thinnerConsumption: '',
|
||||||
|
paintId: '',
|
||||||
|
thinnerId: '',
|
||||||
|
thinnerSymbol: '',
|
||||||
|
colorHex: '#ffffff'
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
api.get('/projects').then(response => {
|
||||||
|
setProjects(response.data);
|
||||||
|
}).catch(err => console.error("Error loading projects", err));
|
||||||
|
} else {
|
||||||
|
setSelectedProjectId(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
api.get('/datasheets').then(response => {
|
||||||
|
console.log('Frontend: Datasheets received:', response.data.length);
|
||||||
|
setDataSheets(response.data);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Frontend: Error loading datasheets:", err);
|
||||||
|
});
|
||||||
|
}, [projectId, isOpen]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setFormData({
|
||||||
|
name: initialData.name || '',
|
||||||
|
type: initialData.type || '',
|
||||||
|
coat: initialData.coat || '',
|
||||||
|
solidsVolume: initialData.solidsVolume?.toString() || '',
|
||||||
|
yieldTheoretical: initialData.yieldTheoretical?.toString() || '',
|
||||||
|
epsMin: initialData.epsMin?.toString() || '',
|
||||||
|
epsMax: initialData.epsMax?.toString() || '',
|
||||||
|
dilution: initialData.dilution?.toString() || '',
|
||||||
|
manufacturer: initialData.manufacturer || '',
|
||||||
|
color: initialData.color || '',
|
||||||
|
notes: initialData.notes || '',
|
||||||
|
paintConsumption: initialData.paintConsumption?.toString() || '',
|
||||||
|
thinnerConsumption: initialData.thinnerConsumption?.toString() || '',
|
||||||
|
paintId: typeof initialData.paintId === 'object' ? (initialData.paintId?._id || '') : (initialData.paintId as string) || '',
|
||||||
|
thinnerId: typeof initialData.thinnerId === 'object' ? (initialData.thinnerId?._id || '') : (initialData.thinnerId as string) || '',
|
||||||
|
thinnerSymbol: initialData.thinnerSymbol || '',
|
||||||
|
colorHex: initialData.colorHex || '#ffffff'
|
||||||
|
});
|
||||||
|
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
name: '', type: '', coat: '', solidsVolume: '', yieldTheoretical: '', epsMin: '', epsMax: '',
|
||||||
|
dilution: '', manufacturer: '', color: '', notes: '',
|
||||||
|
paintConsumption: '', thinnerConsumption: '', paintId: '', thinnerId: '',
|
||||||
|
thinnerSymbol: '',
|
||||||
|
colorHex: '#ffffff'
|
||||||
|
});
|
||||||
|
if (projectId) setSelectedProjectId(projectId);
|
||||||
|
}
|
||||||
|
}, [initialData, isOpen, projectId]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
|
||||||
|
// Mantém a atualização básica do estado
|
||||||
|
setFormData(prev => {
|
||||||
|
const newState = { ...prev, [name]: value };
|
||||||
|
|
||||||
|
// Lógica Proativa: Se mudar o NOME do produto no topo, tenta preencher tudo
|
||||||
|
if (name === 'name' && value) {
|
||||||
|
const ds = dataSheets.find(d => d.name === value);
|
||||||
|
if (ds) {
|
||||||
|
let mappedType = '';
|
||||||
|
const dsTypeNormalized = ds.type?.toLowerCase() || '';
|
||||||
|
if (dsTypeNormalized.includes('epóxi')) mappedType = 'epoxy';
|
||||||
|
else if (dsTypeNormalized.includes('poliuretano')) mappedType = 'polyurethane';
|
||||||
|
else if (dsTypeNormalized.includes('zinco')) mappedType = 'silicate-zinc';
|
||||||
|
else if (dsTypeNormalized.includes('acríl')) mappedType = 'acrylic';
|
||||||
|
else if (dsTypeNormalized.includes('alquíd')) mappedType = 'alkyd';
|
||||||
|
|
||||||
|
// Se a tinta tem um redutor na ficha, tenta achar o ID dele na biblioteca
|
||||||
|
let thinnerId = ''; // Resetar redutor ao trocar a tinta
|
||||||
|
if (ds.reducer) {
|
||||||
|
const reducerCode = ds.reducer.trim().toLowerCase();
|
||||||
|
console.log(`Auto-fill: Looking for reducer "${reducerCode}" for paint "${ds.name}"`);
|
||||||
|
|
||||||
|
// Busca flexível: exata ou contém
|
||||||
|
const matchingReducer = dataSheets.find(d =>
|
||||||
|
d.name.toLowerCase() === reducerCode ||
|
||||||
|
d.name.toLowerCase().includes(reducerCode) ||
|
||||||
|
reducerCode.includes(d.name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingReducer) {
|
||||||
|
thinnerId = matchingReducer._id || matchingReducer.id || '';
|
||||||
|
console.log(`Auto-fill: Found matching reducer: ${matchingReducer.name}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Auto-fill: No matching reducer found in library for "${reducerCode}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...newState,
|
||||||
|
type: mappedType || newState.type,
|
||||||
|
solidsVolume: ds.solidsVolume?.toString() || newState.solidsVolume,
|
||||||
|
yieldTheoretical: ds.yieldTheoretical?.toString() || newState.yieldTheoretical,
|
||||||
|
epsMin: ds.dftMin?.toString() || newState.epsMin,
|
||||||
|
epsMax: ds.dftMax?.toString() || newState.epsMax,
|
||||||
|
manufacturer: ds.manufacturer || newState.manufacturer,
|
||||||
|
dilution: ds.dilution?.toString() || newState.dilution,
|
||||||
|
paintId: ds._id || ds.id || newState.paintId,
|
||||||
|
thinnerId: thinnerId,
|
||||||
|
thinnerSymbol: ds.reducer || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("Submitting form with data:", formData);
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedProjectId) {
|
||||||
|
alert("Selecione um projeto");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
solidsVolume: parseInt(formData.solidsVolume),
|
||||||
|
yieldTheoretical: parseFloat(formData.yieldTheoretical),
|
||||||
|
epsMin: parseFloat(formData.epsMin),
|
||||||
|
epsMax: parseFloat(formData.epsMax),
|
||||||
|
dilution: parseInt(formData.dilution),
|
||||||
|
paintConsumption: parseFloat(formData.paintConsumption),
|
||||||
|
thinnerConsumption: parseFloat(formData.thinnerConsumption),
|
||||||
|
paintId: formData.paintId || null,
|
||||||
|
thinnerId: formData.thinnerId || null,
|
||||||
|
thinnerSymbol: formData.thinnerSymbol
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
await api.put(`/painting-schemes/${initialData.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/painting-schemes', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving scheme', error);
|
||||||
|
alert('Erro ao salvar esquema');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Esquema" : "Novo Esquema / Demão"}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{!projectId && (
|
||||||
|
<Select
|
||||||
|
name="projectId"
|
||||||
|
label="Projeto"
|
||||||
|
options={projects.map(p => ({ label: p.name, value: p.id }))}
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Select
|
||||||
|
name="name"
|
||||||
|
label="Nome/Descrição (Produto)"
|
||||||
|
options={[
|
||||||
|
{ label: 'Outro (Manual)', value: '' },
|
||||||
|
...dataSheets.map(ds => ({ label: ds.name, value: ds.name }))
|
||||||
|
]}
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{formData.name === '' && (
|
||||||
|
<Input name="name" label="Descrição Manual" placeholder="Ex: Pintura Interna" value={formData.name} onChange={handleChange} required />
|
||||||
|
)}
|
||||||
|
<Select
|
||||||
|
name="coat"
|
||||||
|
label="Demão (Etapa)"
|
||||||
|
options={[
|
||||||
|
{ label: 'Primer / Selador', value: 'Primer' },
|
||||||
|
{ label: 'Stripe Coat', value: 'Stripe Coat' },
|
||||||
|
{ label: 'Intermediário', value: 'Intermediario' },
|
||||||
|
{ label: 'Acabamento', value: 'Acabamento' },
|
||||||
|
{ label: 'Retoque', value: 'Retoque' }
|
||||||
|
]}
|
||||||
|
value={formData.coat}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
name="type"
|
||||||
|
label="Tipo de Tinta"
|
||||||
|
options={[
|
||||||
|
{ label: 'Epóxi', value: 'epoxy' },
|
||||||
|
{ label: 'Poliuretano', value: 'polyurethane' },
|
||||||
|
{ label: 'Silicato Zinco', value: 'silicate-zinc' },
|
||||||
|
{ label: 'Acrílica', value: 'acrylic' },
|
||||||
|
{ label: 'Alquídica', value: 'alkyd' }
|
||||||
|
]}
|
||||||
|
value={formData.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<Input name="solidsVolume" label="Sólidos Vol. (%)" type="number" value={formData.solidsVolume} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="yieldTheoretical" label="Rendimento (m²/L)" type="number" step="0.01" value={formData.yieldTheoretical} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="epsMin" label="EPS Mín (μm)" type="number" value={formData.epsMin} onChange={handleChange} />
|
||||||
|
<Input name="epsMax" label="EPS Máx (μm)" type="number" value={formData.epsMax} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="dilution" label="Diluição (%)" type="number" value={formData.dilution} onChange={handleChange} />
|
||||||
|
<Input name="manufacturer" label="Fabricante" value={formData.manufacturer} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="color" label="Cor (Munsell/RAL)" placeholder="Ex: N6.5 ou RAL 7035" value={formData.color} onChange={handleChange} />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Cor Representativa</label>
|
||||||
|
<div className="flex items-center gap-3 bg-surface-soft/50 p-2 rounded-xl border border-border/40">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
name="colorHex"
|
||||||
|
value={formData.colorHex}
|
||||||
|
onChange={handleChange}
|
||||||
|
title="Cor Representativa"
|
||||||
|
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono font-bold text-text-muted">{formData.colorHex}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
aria-label="Observações"
|
||||||
|
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mt-4">
|
||||||
|
<h3 className="text-sm font-bold text-text-main mb-3">Planejamento de Consumo (Opcional)</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
name="paintId"
|
||||||
|
label="Produto (Tinta)"
|
||||||
|
options={[
|
||||||
|
{ label: 'Selecione...', value: '' },
|
||||||
|
...dataSheets.map(ds => ({ label: ds.name, value: ds._id || ds.id || '' }))
|
||||||
|
]}
|
||||||
|
value={formData.paintId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedPaintId = e.target.value;
|
||||||
|
setFormData(prev => {
|
||||||
|
const ds = dataSheets.find(d => (d._id || d.id) === selectedPaintId);
|
||||||
|
let thinnerId = ''; // Resetar ao trocar tinta
|
||||||
|
|
||||||
|
if (ds && ds.reducer) {
|
||||||
|
const reducerClean = ds.reducer.trim().toLowerCase();
|
||||||
|
console.log(`Consumption: Looking for reducer "${reducerClean}" for paint ID ${selectedPaintId}`);
|
||||||
|
|
||||||
|
// Busca exata ou por inclusão
|
||||||
|
const matchingThinner = dataSheets.find(d =>
|
||||||
|
d.name.toLowerCase() === reducerClean ||
|
||||||
|
d.name.toLowerCase().includes(reducerClean) ||
|
||||||
|
reducerClean.includes(d.name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingThinner) {
|
||||||
|
thinnerId = matchingThinner._id || matchingThinner.id || '';
|
||||||
|
console.log(`Consumption: Found reducer match: ${matchingThinner.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...prev, paintId: selectedPaintId, thinnerId, thinnerSymbol: ds?.reducer || '' };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="thinnerSymbol"
|
||||||
|
label="Redutor (diluente)"
|
||||||
|
value={formData.thinnerSymbol}
|
||||||
|
readOnly
|
||||||
|
placeholder="Preenchido pela tinta"
|
||||||
|
className="bg-surface-soft/50 font-bold text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-2">
|
||||||
|
<Input
|
||||||
|
name="paintConsumption"
|
||||||
|
label="Consumo Tinta (L/Kg)"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
value={formData.paintConsumption}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
name="thinnerConsumption"
|
||||||
|
label="Consumo Diluente (L/Kg)"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
value={formData.thinnerConsumption}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Adicionar Demão')}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
167
src/client/components/modals/CreatePartModal.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import * as geometryService from '../../services/geometryTypeService';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import type { Part, GeometryType } from '../../types';
|
||||||
|
|
||||||
|
interface CreatePartModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
projectId?: string;
|
||||||
|
initialData?: Part;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreatePartModal: React.FC<CreatePartModalProps> = ({ isOpen, onClose, onSuccess, projectId, initialData }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [projects, setProjects] = useState<{ id: string, name: string }[]>([]);
|
||||||
|
const [geometryTypes, setGeometryTypes] = useState<GeometryType[]>([]);
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState(projectId || '');
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
description: '',
|
||||||
|
dimensions: '',
|
||||||
|
weight: '',
|
||||||
|
type: '',
|
||||||
|
area: '',
|
||||||
|
complexity: '',
|
||||||
|
quantity: '1',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setFormData({
|
||||||
|
description: initialData.description || '',
|
||||||
|
dimensions: initialData.dimensions || '',
|
||||||
|
weight: initialData.weight?.toString() || '',
|
||||||
|
type: initialData.type || '',
|
||||||
|
area: initialData.area?.toString() || '',
|
||||||
|
complexity: initialData.complexity?.toString() || '',
|
||||||
|
quantity: initialData.quantity?.toString() || '1',
|
||||||
|
notes: initialData.notes || ''
|
||||||
|
});
|
||||||
|
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
|
||||||
|
} else {
|
||||||
|
setFormData({ description: '', dimensions: '', weight: '', type: '', area: '', complexity: '', quantity: '1', notes: '' });
|
||||||
|
if (projectId) setSelectedProjectId(projectId);
|
||||||
|
}
|
||||||
|
}, [initialData, isOpen, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (!projectId) {
|
||||||
|
api.get('/projects')
|
||||||
|
.then(res => setProjects(res.data))
|
||||||
|
.catch(err => console.error("Error fetching projects", err));
|
||||||
|
}
|
||||||
|
geometryService.getAllTypes()
|
||||||
|
.then(res => setGeometryTypes(res.data))
|
||||||
|
.catch(err => console.error("Error fetching geometry types", err));
|
||||||
|
}
|
||||||
|
}, [isOpen, projectId]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectToUse = projectId || selectedProjectId;
|
||||||
|
if (!projectToUse) {
|
||||||
|
alert("Por favor, selecione um projeto.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
description: formData.type, // Usar o tipo como descrição
|
||||||
|
projectId: projectToUse,
|
||||||
|
dimensions: formData.dimensions || undefined,
|
||||||
|
weight: formData.weight ? parseFloat(formData.weight) : undefined,
|
||||||
|
type: formData.type || undefined,
|
||||||
|
area: formData.area ? parseFloat(formData.area) : undefined,
|
||||||
|
quantity: formData.quantity ? parseInt(formData.quantity) : 1,
|
||||||
|
notes: formData.notes || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
await api.put(`/parts/${initialData.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post('/parts', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving part', error);
|
||||||
|
alert('Erro ao salvar peça');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Peça" : "Nova Peça / Geometria"}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{!projectId && (
|
||||||
|
<Select
|
||||||
|
name="projectId"
|
||||||
|
label="Projeto / Obra"
|
||||||
|
options={projects.map(p => ({ label: p.name, value: p.id }))}
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
name="type"
|
||||||
|
label="Tipo Geometria"
|
||||||
|
options={[
|
||||||
|
{ label: 'Selecione...', value: '' },
|
||||||
|
...geometryTypes.map(t => ({ label: t.name, value: t.name }))
|
||||||
|
]}
|
||||||
|
value={formData.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="weight" label="Kg estimado do lote" type="number" step="0.1" value={formData.weight} onChange={handleChange} />
|
||||||
|
<Input name="area" label="Área Superfície (m²)" type="number" step="0.01" value={formData.area} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 w-full">
|
||||||
|
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
aria-label="Observações"
|
||||||
|
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Adicionar Peça')}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
215
src/client/components/modals/CreateProjectModal.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { FileDown, Plus } from 'lucide-react';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
|
||||||
|
import { CreatePaintingSchemeModal } from './CreatePaintingSchemeModal';
|
||||||
|
import { ImportSchemeModal } from './ImportSchemeModal';
|
||||||
|
|
||||||
|
import type { Project } from '../../types';
|
||||||
|
|
||||||
|
interface CreateProjectModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
initialData?: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose, onSuccess, initialData }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [step, setStep] = useState<'form' | 'success'>('form');
|
||||||
|
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sub-modals state
|
||||||
|
const [showSchemeModal, setShowSchemeModal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
client: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
technician: '',
|
||||||
|
environment: '',
|
||||||
|
weightKg: '', // Text input for number
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setStep('form');
|
||||||
|
setCreatedProjectId(null);
|
||||||
|
if (initialData) {
|
||||||
|
setFormData({
|
||||||
|
name: initialData.name || '',
|
||||||
|
client: initialData.client || '',
|
||||||
|
startDate: initialData.startDate ? new Date(initialData.startDate).toISOString().split('T')[0] : '',
|
||||||
|
endDate: initialData.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : '',
|
||||||
|
technician: initialData.technician || '',
|
||||||
|
environment: initialData.environment || '',
|
||||||
|
weightKg: initialData.weightKg ? String(initialData.weightKg) : ''
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({ name: '', client: '', startDate: '', endDate: '', technician: '', environment: '', weightKg: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialData, isOpen]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('Submit button clicked. Current formData:', formData);
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
console.warn('Submission blocked: user is guest');
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.name || !formData.client) {
|
||||||
|
console.warn('Submission blocked: required fields missing', { name: !!formData.name, client: !!formData.client });
|
||||||
|
alert('Por favor, preencha o Nome do Projeto e o Cliente.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
weightKg: formData.weightKg ? parseFloat(formData.weightKg) : null
|
||||||
|
};
|
||||||
|
console.log('Sending project payload to backend...', payload);
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
console.log('Updating project:', initialData.id);
|
||||||
|
const res = await api.put(`/projects/${initialData.id}`, payload);
|
||||||
|
console.log('Project updated successfully:', res.data);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
console.log('Posting new project...');
|
||||||
|
const res = await api.post('/projects', payload);
|
||||||
|
console.log('Project created response:', res.data);
|
||||||
|
|
||||||
|
const pid = res.data.id || res.data._id;
|
||||||
|
if (!pid) {
|
||||||
|
console.error('No ID returned from create project:', res.data);
|
||||||
|
throw new Error('ID do projeto não retornado pelo servidor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatedProjectId(pid);
|
||||||
|
onSuccess();
|
||||||
|
setStep('success');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error saving project:', error);
|
||||||
|
const axiosError = error as any;
|
||||||
|
const errorMsg = axiosError.response?.data?.error || axiosError.message || 'Erro desconhecido';
|
||||||
|
alert(`Erro ao salvar projeto: ${errorMsg}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
// If in success step, we just finish
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={isOpen} onClose={handleClose} title={step === 'form' ? (initialData ? "Editar Projeto" : "Novo Projeto") : "Projeto Criado com Sucesso!"}>
|
||||||
|
{step === 'form' ? (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input name="name" label="Nome do Projeto" required value={formData.name} onChange={handleChange} />
|
||||||
|
<Input name="client" label="Cliente" required value={formData.client} onChange={handleChange} />
|
||||||
|
<Input name="technician" label="Responsável Técnico" value={formData.technician} onChange={handleChange} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input name="startDate" label="Início Planejado" type="date" value={formData.startDate} onChange={handleChange} />
|
||||||
|
<Input name="endDate" label="Fim Planejado" type="date" value={formData.endDate} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
<Input name="weightKg" label="Peso Total (Kg)" type="number" placeholder="0.00" value={formData.weightKg} onChange={handleChange} />
|
||||||
|
<Select
|
||||||
|
name="environment"
|
||||||
|
label="Ambiente (Corrosividade)"
|
||||||
|
options={[
|
||||||
|
{ label: 'C1 - Muito Baixa', value: 'C1' },
|
||||||
|
{ label: 'C2 - Baixa', value: 'C2' },
|
||||||
|
{ label: 'C3 - Média', value: 'C3' },
|
||||||
|
{ label: 'C4 - Alta', value: 'C4' },
|
||||||
|
{ label: 'C5 - Muito Alta', value: 'C5' },
|
||||||
|
{ label: 'CX - Extrema', value: 'CX' }
|
||||||
|
]}
|
||||||
|
value={formData.environment}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={handleClose} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Criar Projeto')}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6 text-center py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl font-bold text-text-main">Configurar Esquema de Pintura?</h3>
|
||||||
|
<p className="text-sm text-text-muted px-4">O projeto foi criado. Deseja adicionar um esquema de pintura agora?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="p-3 rounded-full bg-surface-soft group-hover:bg-white text-primary transition-colors">
|
||||||
|
<FileDown size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-sm text-text-main group-hover:text-primary">Importar de Obra</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSchemeModal(true)}
|
||||||
|
className="flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="p-3 rounded-full bg-surface-soft group-hover:bg-white text-primary transition-colors">
|
||||||
|
<Plus size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-sm text-text-main group-hover:text-primary">Criar Novo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-border/40">
|
||||||
|
<Button variant="ghost" className="w-full" onClick={handleClose}>Pular / Finalizar</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{createdProjectId && (
|
||||||
|
<>
|
||||||
|
<CreatePaintingSchemeModal
|
||||||
|
isOpen={showSchemeModal}
|
||||||
|
onClose={() => setShowSchemeModal(false)}
|
||||||
|
onSuccess={() => { setShowSchemeModal(false); onClose(); }}
|
||||||
|
projectId={createdProjectId}
|
||||||
|
/>
|
||||||
|
<ImportSchemeModal
|
||||||
|
isOpen={showImportModal}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onSuccess={() => { setShowImportModal(false); onClose(); }}
|
||||||
|
targetProjectId={createdProjectId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
175
src/client/components/modals/DiluentListModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Edit, Trash2, Plus } from 'lucide-react';
|
||||||
|
import { DiluentRegistrationModal } from './DiluentRegistrationModal';
|
||||||
|
import { getDataSheets, deleteDataSheet } from '../../services/dataSheetService';
|
||||||
|
import type { TechnicalDataSheet } from '../../types';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
|
||||||
|
interface DiluentListModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DiluentListModal: React.FC<DiluentListModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [diluents, setDiluents] = useState<TechnicalDataSheet[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// State for the registration/edit modal
|
||||||
|
const [showFormModal, setShowFormModal] = useState(false);
|
||||||
|
const [selectedDiluent, setSelectedDiluent] = useState<TechnicalDataSheet | undefined>(undefined);
|
||||||
|
|
||||||
|
const fetchDiluents = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getDataSheets();
|
||||||
|
// Filter only THINNER types
|
||||||
|
const filtered = response.data.filter(ds =>
|
||||||
|
ds.type === 'THINNER' || ds.type === 'DILUENTE'
|
||||||
|
);
|
||||||
|
setDiluents(filtered);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching diluents:', error);
|
||||||
|
showToast('Erro ao carregar lista de diluentes.', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchDiluents();
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchDiluents]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
if (confirm(`Tem certeza que deseja excluir o diluente "${name}" ? `)) {
|
||||||
|
try {
|
||||||
|
await deleteDataSheet(id);
|
||||||
|
showToast('Diluente excluído com sucesso.', 'success');
|
||||||
|
fetchDiluents();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting diluent:', error);
|
||||||
|
showToast('Erro ao excluir diluente.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (diluent: TechnicalDataSheet) => {
|
||||||
|
setSelectedDiluent(diluent);
|
||||||
|
setShowFormModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNew = () => {
|
||||||
|
setSelectedDiluent(undefined);
|
||||||
|
setShowFormModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Gerenciar Diluentes"
|
||||||
|
maxWidth="max-w-4xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-text-muted text-sm">
|
||||||
|
Lista de diluentes cadastrados no sistema.
|
||||||
|
</p>
|
||||||
|
{isAdmin() && (
|
||||||
|
<Button onClick={handleNew} className="flex items-center gap-2">
|
||||||
|
<Plus size={16} />
|
||||||
|
Novo Diluente
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface rounded-xl border border-border/40 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-text-muted">Carregando...</div>
|
||||||
|
) : diluents.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-text-muted">Nenhum diluente encontrado.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-surface-soft border-b border-border/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Nome</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Fabricante</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Estoque Mín.</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-bold text-text-muted uppercase">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/40">
|
||||||
|
{diluents.map((diluent) => (
|
||||||
|
<tr key={diluent._id || diluent.id} className="hover:bg-surface-hover transition-colors">
|
||||||
|
<td className="px-6 py-3 text-sm font-medium text-text-main">
|
||||||
|
{diluent.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-text-secondary">
|
||||||
|
{diluent.manufacturer}
|
||||||
|
{diluent.manufacturerCode && (
|
||||||
|
<span className="text-xs text-text-muted block">
|
||||||
|
{diluent.manufacturerCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-text-secondary">
|
||||||
|
{diluent.minStock ? `${diluent.minStock} L` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right flex justify-end gap-2">
|
||||||
|
{isAdmin() && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(diluent)}
|
||||||
|
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(diluent._id || diluent.id, diluent.name)}
|
||||||
|
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Excluir"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{showFormModal && (
|
||||||
|
<DiluentRegistrationModal
|
||||||
|
isOpen={showFormModal}
|
||||||
|
onClose={() => setShowFormModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
fetchDiluents();
|
||||||
|
setShowFormModal(false);
|
||||||
|
}}
|
||||||
|
initialData={selectedDiluent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
150
src/client/components/modals/DiluentRegistrationModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import type { TechnicalDataSheet } from '../../types';
|
||||||
|
import { createDataSheet, updateDataSheet } from '../../services/dataSheetService';
|
||||||
|
|
||||||
|
interface DiluentRegistrationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
initialData?: TechnicalDataSheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DiluentRegistrationModal: React.FC<DiluentRegistrationModalProps> = ({ isOpen, onClose, onSuccess, initialData }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Form Data
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [manufacturer, setManufacturer] = useState('');
|
||||||
|
const [manufacturerCode, setManufacturerCode] = useState('');
|
||||||
|
const [minStock, setMinStock] = useState('');
|
||||||
|
const [typicalApplication, setTypicalApplication] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (initialData) {
|
||||||
|
setName(initialData.name);
|
||||||
|
setManufacturer(initialData.manufacturer || '');
|
||||||
|
setManufacturerCode(initialData.manufacturerCode || '');
|
||||||
|
setMinStock(String(initialData.minStock || ''));
|
||||||
|
setTypicalApplication(initialData.typicalApplication || '');
|
||||||
|
} else {
|
||||||
|
setName('');
|
||||||
|
setManufacturer('');
|
||||||
|
setManufacturerCode('');
|
||||||
|
setMinStock('');
|
||||||
|
setTypicalApplication('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, initialData]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('manufacturer', manufacturer);
|
||||||
|
formData.append('manufacturerCode', manufacturerCode);
|
||||||
|
formData.append('minStock', String(Number(minStock) || 0));
|
||||||
|
formData.append('typicalApplication', typicalApplication);
|
||||||
|
formData.append('type', 'THINNER');
|
||||||
|
|
||||||
|
// Ensure fileUrl is handled if required by backend, existing logic used a placeholder string
|
||||||
|
if (!initialData) {
|
||||||
|
formData.append('fileUrl', 'placeholder_url');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (initialData && (initialData._id || initialData.id)) {
|
||||||
|
await updateDataSheet(initialData._id || initialData.id, formData);
|
||||||
|
} else {
|
||||||
|
await createDataSheet(formData);
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving diluent:', error);
|
||||||
|
alert(error.response?.data?.error || 'Erro ao salvar diluente.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={initialData ? "Editar Diluente" : "Cadastrar Novo Diluente"}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Nome do Diluente"
|
||||||
|
name="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Ex: Diluente Epóxi 123"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Fabricante"
|
||||||
|
name="manufacturer"
|
||||||
|
value={manufacturer}
|
||||||
|
onChange={(e) => setManufacturer(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Ex: Sherwin Williams"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Cód. Fabricante"
|
||||||
|
name="manufacturerCode"
|
||||||
|
value={manufacturerCode}
|
||||||
|
onChange={(e) => setManufacturerCode(e.target.value)}
|
||||||
|
placeholder="Ex: REF-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Estoque Mínimo (L)"
|
||||||
|
name="minStock"
|
||||||
|
type="number"
|
||||||
|
value={minStock}
|
||||||
|
onChange={(e) => setMinStock(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Aplicação Típica"
|
||||||
|
name="typicalApplication"
|
||||||
|
value={typicalApplication}
|
||||||
|
onChange={(e) => setTypicalApplication(e.target.value)}
|
||||||
|
placeholder="Ex: Diluição de tintas epóxi série X"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Salvando...' : 'Salvar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
183
src/client/components/modals/ImportSchemeModal.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import type { Project, PaintingScheme } from '../../types';
|
||||||
|
|
||||||
|
interface ImportSchemeModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
targetProjectId: string;
|
||||||
|
isExchangeMode?: boolean;
|
||||||
|
hasInspections?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportSchemeModal: React.FC<ImportSchemeModalProps> = ({ isOpen, onClose, onSuccess, targetProjectId, isExchangeMode, hasInspections }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [schemes, setSchemes] = useState<PaintingScheme[]>([]);
|
||||||
|
|
||||||
|
const [sourceProjectId, setSourceProjectId] = useState('');
|
||||||
|
const [sourceSchemeId, setSourceSchemeId] = useState('');
|
||||||
|
const [shouldReplace, setShouldReplace] = useState(false);
|
||||||
|
|
||||||
|
// Initial state setup
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Default replace to TRUE if in exchange mode and allowed (no inspections)
|
||||||
|
if (isExchangeMode && !hasInspections) {
|
||||||
|
setShouldReplace(true);
|
||||||
|
} else {
|
||||||
|
setShouldReplace(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
api.get('/projects').then(res => {
|
||||||
|
const otherProjects = res.data.filter((p: Project) => p.id !== targetProjectId);
|
||||||
|
setProjects(otherProjects);
|
||||||
|
}).catch(err => console.error("Error loading projects", err));
|
||||||
|
} else {
|
||||||
|
setSourceProjectId('');
|
||||||
|
setSourceSchemeId('');
|
||||||
|
setSchemes([]);
|
||||||
|
setShouldReplace(false);
|
||||||
|
}
|
||||||
|
}, [isOpen, targetProjectId, isExchangeMode, hasInspections]);
|
||||||
|
|
||||||
|
// Fetch schemes when project selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (sourceProjectId) {
|
||||||
|
api.get(`/painting-schemes?projectId=${sourceProjectId}`).then(res => {
|
||||||
|
const projectSchemes = res.data.filter((s: PaintingScheme) => s.projectId === sourceProjectId);
|
||||||
|
setSchemes(projectSchemes);
|
||||||
|
}).catch(err => console.error("Error loading schemes", err));
|
||||||
|
} else {
|
||||||
|
setSchemes([]);
|
||||||
|
}
|
||||||
|
}, [sourceProjectId]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceSchemeId) return;
|
||||||
|
if (!targetProjectId) {
|
||||||
|
alert("Erro: Projeto de destino não identificado. Tente recarregar a página.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// If Replacing, first delete ALL existing schemes for this project
|
||||||
|
if (shouldReplace && isExchangeMode && !hasInspections) {
|
||||||
|
// 1. Fetch current schemes
|
||||||
|
const currentSchemesRes = await api.get(`/painting-schemes?projectId=${targetProjectId}`);
|
||||||
|
const currentSchemes = currentSchemesRes.data.filter((s: PaintingScheme) => s.projectId === targetProjectId);
|
||||||
|
|
||||||
|
// 2. Delete them
|
||||||
|
await Promise.all(currentSchemes.map((s: PaintingScheme) => api.delete(`/painting-schemes/${s.id}`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemeToClone = schemes.find(s => s.id === sourceSchemeId);
|
||||||
|
if (!schemeToClone) throw new Error("Scheme not found");
|
||||||
|
|
||||||
|
// Clone and remove ID/Project specific fields to create a fresh copy
|
||||||
|
const schemeData = { ...(schemeToClone as any) };
|
||||||
|
delete schemeData.id;
|
||||||
|
delete schemeData.projectId;
|
||||||
|
delete schemeData._id;
|
||||||
|
delete schemeData.__v;
|
||||||
|
delete schemeData.createdAt;
|
||||||
|
delete schemeData.updatedAt;
|
||||||
|
|
||||||
|
await api.post('/painting-schemes', {
|
||||||
|
...schemeData,
|
||||||
|
projectId: targetProjectId,
|
||||||
|
// If replacing, keep original name? User asked to "Exchange". Maybe we don't need "(Cópia)" suffix if strictly exchanging.
|
||||||
|
// But safer to keep distinct unless user renames. Let's keep existing logic or maybe drop suffix if replacing?
|
||||||
|
// Step 1251 prompt implies "Troca Limpa". A clean swap usually implies taking the new scheme AS IS.
|
||||||
|
name: shouldReplace ? schemeData.name : `${schemeData.name} (Cópia)`
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing scheme', error);
|
||||||
|
alert('Erro ao importar esquema');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={isExchangeMode ? "Trocar / Importar Esquema" : "Importar Esquema de Pintura"}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<p className="text-sm text-text-muted">Selecione uma obra existente para copiar seu esquema de pintura.</p>
|
||||||
|
|
||||||
|
{isExchangeMode && hasInspections && (
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 items-start">
|
||||||
|
<div className="mt-1 text-amber-600 font-bold text-xs uppercase">Atenção</div>
|
||||||
|
<div className="text-xs text-amber-700">
|
||||||
|
Esta obra já possui inspeções ou registros cadastrados. Por segurança, <strong>não é permitido substituir</strong> o esquema atual, apenas adicionar novos itens.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExchangeMode && !hasInspections && (
|
||||||
|
<div className="bg-surface-soft p-3 rounded-lg border border-border flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="replace-check"
|
||||||
|
className="w-4 h-4 text-primary rounded border focus:ring-primary"
|
||||||
|
checked={shouldReplace}
|
||||||
|
onChange={(e) => setShouldReplace(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="replace-check" className="text-sm font-medium text-text-main cursor-pointer select-none">
|
||||||
|
Substituir todos os esquemas atuais (Limpar Obra)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
name="sourceProject"
|
||||||
|
label="Obra/Projeto de Origem"
|
||||||
|
options={projects.map(p => ({ label: p.name, value: p.id }))}
|
||||||
|
value={sourceProjectId}
|
||||||
|
onChange={(e) => setSourceProjectId(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
name="sourceScheme"
|
||||||
|
label="Esquema de Pintura"
|
||||||
|
options={schemes.map(s => ({ label: s.name, value: s.id }))}
|
||||||
|
value={sourceSchemeId}
|
||||||
|
onChange={(e) => setSourceSchemeId(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={!sourceProjectId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{schemes.length === 0 && sourceProjectId && (
|
||||||
|
<p className="text-xs text-amber-500 font-bold">Esta obra não possui esquemas cadastrados.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={loading || !sourceSchemeId}>
|
||||||
|
{loading ? 'Processando...' : (shouldReplace ? 'Trocar Esquema' : 'Adicionar Cópia')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
377
src/client/components/modals/StockHistoryModal.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { stockService, type StockMovement, type StockItem } from '../../services/stockService';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ArrowUp, ArrowDown, RefreshCw, Trash2, Edit2, Save, X, FileText, Activity } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
|
||||||
|
interface StockHistoryModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
item: StockItem;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
_id: string;
|
||||||
|
action: 'CREATE' | 'UPDATE' | 'DELETE';
|
||||||
|
userName: string;
|
||||||
|
details: string;
|
||||||
|
timestamp: string;
|
||||||
|
movementNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({ isOpen, onClose, item, onUpdate }) => {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const [activeTab, setActiveTab] = useState<'movements' | 'logs'>('movements');
|
||||||
|
const [movements, setMovements] = useState<StockMovement[]>([]);
|
||||||
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
|
const [currentItem, setCurrentItem] = useState<StockItem>(item);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [editValues, setEditValues] = useState<{ date: string; quantity: string; notes: string }>({
|
||||||
|
date: '',
|
||||||
|
quantity: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (item._id) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Always fetch item to keep balance fresh
|
||||||
|
const itemData = await stockService.getById(item._id);
|
||||||
|
setCurrentItem(itemData);
|
||||||
|
|
||||||
|
if (activeTab === 'movements') {
|
||||||
|
const data = await stockService.getMovements(item._id);
|
||||||
|
setMovements(data);
|
||||||
|
} else {
|
||||||
|
const data = await stockService.getAuditLogs(item._id);
|
||||||
|
setLogs(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [isOpen, item, activeTab]);
|
||||||
|
|
||||||
|
const formatMovementId = (num?: number) => {
|
||||||
|
if (!num) return '-';
|
||||||
|
return `${item.rrNumber}/${String(num).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLogs = logs.filter(log => {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
const formattedId = formatMovementId(log.movementNumber);
|
||||||
|
return (
|
||||||
|
log.details.toLowerCase().includes(term) ||
|
||||||
|
log.userName.toLowerCase().includes(term) ||
|
||||||
|
formattedId.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEditClick = (move: StockMovement) => {
|
||||||
|
setEditingId(move._id!);
|
||||||
|
const dateStr = new Date(move.date).toISOString().slice(0, 16);
|
||||||
|
setEditValues({
|
||||||
|
date: dateStr,
|
||||||
|
quantity: String(move.quantity),
|
||||||
|
notes: move.notes || ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditValues({ date: '', quantity: '', notes: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await stockService.updateMovement(id, {
|
||||||
|
date: new Date(editValues.date).toISOString(),
|
||||||
|
quantity: Number(editValues.quantity),
|
||||||
|
notes: editValues.notes
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
fetchData();
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating movement:', error);
|
||||||
|
alert('Erro ao atualizar movimentação.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, qty: number) => {
|
||||||
|
if (confirm(`Tem certeza que deseja excluir esta movimentação de ${qty}? O saldo do lote será revertido.`)) {
|
||||||
|
try {
|
||||||
|
await stockService.deleteMovement(id);
|
||||||
|
fetchData();
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting movement:', error);
|
||||||
|
alert('Erro ao excluir movimentação.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMovementIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'ENTRY': return <ArrowUp size={16} className="text-green-500" />;
|
||||||
|
case 'CONSUMPTION': return <ArrowDown size={16} className="text-blue-500" />;
|
||||||
|
case 'ADJUSTMENT': return <RefreshCw size={16} className="text-amber-500" />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMovementLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'ENTRY': return 'Entrada';
|
||||||
|
case 'CONSUMPTION': return 'Consumo';
|
||||||
|
case 'ADJUSTMENT': return 'Ajuste';
|
||||||
|
default: return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Histórico - ${item.rrNumber}`}
|
||||||
|
maxWidth="max-w-4xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mb-4 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-text-secondary">Produto: <span className="text-text-main font-semibold">{typeof currentItem.dataSheetId === 'object' ? currentItem.dataSheetId.name : '...'}</span></p>
|
||||||
|
<p className="text-sm text-text-secondary">Lote: <span className="text-text-main font-semibold">{currentItem.batchNumber}</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-text-secondary">Saldo Atual</p>
|
||||||
|
<span className="text-text-main font-bold text-2xl">{currentItem.quantity} <span className="text-lg font-normal text-text-muted">{currentItem.unit}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-border/40 mb-4 justify-between items-center">
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('movements')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'movements' ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text-main'}`}
|
||||||
|
>
|
||||||
|
<Activity size={16} />
|
||||||
|
Movimentações
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'logs' ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text-main'}`}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
Logs de Auditoria
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nº Mov ou Detalhes..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="text-xs bg-surface border border-border/40 rounded-lg px-3 py-1.5 focus:outline-none focus:border-primary w-64 text-text-main placeholder-text-muted"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-text-muted">Carregando...</div>
|
||||||
|
) : activeTab === 'movements' ? (
|
||||||
|
// MOVEMENTS TABLE
|
||||||
|
movements.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-text-muted">Nenhuma movimentação registrada.</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-border/40 bg-surface">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-surface-soft text-text-muted font-medium uppercase text-xs">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 w-32 text-center">ID</th>
|
||||||
|
<th className="px-4 py-3 w-40">Data</th>
|
||||||
|
<th className="px-4 py-3 w-32">Tipo</th>
|
||||||
|
<th className="px-4 py-3 w-28">Qtd</th>
|
||||||
|
<th className="px-4 py-3 w-40">Responsável</th>
|
||||||
|
<th className="px-4 py-3">Detalhes</th>
|
||||||
|
{isAdmin() && <th className="px-4 py-3 w-24 text-right">Ações</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/40">
|
||||||
|
{movements.map((move: any) => {
|
||||||
|
const isEditing = editingId === move._id;
|
||||||
|
return (
|
||||||
|
<tr key={move._id} className="hover:bg-surface-hover/50">
|
||||||
|
<td className="px-4 py-3 text-center font-mono text-text-muted text-xs">
|
||||||
|
{formatMovementId(move.movementNumber)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-main align-top">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={editValues.date}
|
||||||
|
onChange={(e) => setEditValues({ ...editValues, date: e.target.value })}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1 text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
{format(new Date(move.date), 'dd/MM/yyyy HH:mm')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
{getMovementIcon(move.type)}
|
||||||
|
<span>{getMovementLabel(move.type)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-bold align-top">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editValues.quantity}
|
||||||
|
onChange={(e) => setEditValues({ ...editValues, quantity: e.target.value })}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1 text-xs"
|
||||||
|
placeholder="(ex: -10)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={move.quantity > 0 ? 'text-green-500' : 'text-red-500'}>
|
||||||
|
{move.quantity > 0 ? '+' : ''}{move.quantity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary align-top text-xs">
|
||||||
|
<div className="line-clamp-2" title={move.responsible}>
|
||||||
|
{move.responsible}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted text-xs align-top">
|
||||||
|
{isEditing ? (
|
||||||
|
<textarea
|
||||||
|
value={editValues.notes}
|
||||||
|
onChange={(e) => setEditValues({ ...editValues, notes: e.target.value })}
|
||||||
|
className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-y min-h-[2.5rem]"
|
||||||
|
placeholder="Notas..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="line-clamp-2">
|
||||||
|
{move.type === 'ADJUSTMENT' && move.reason}
|
||||||
|
{move.type === 'CONSUMPTION' && `Solicitante: ${move.requester}`}
|
||||||
|
{move.notes && ` - ${move.notes}`}
|
||||||
|
{!move.notes && !move.reason && !move.requester && '-'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isAdmin() && (
|
||||||
|
<td className="px-4 py-3 text-right align-top">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSave(move._id!)}
|
||||||
|
className="p-1.5 bg-green-500/10 text-green-500 hover:bg-green-500/20 rounded-lg transition-colors"
|
||||||
|
title="Salvar"
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="p-1.5 bg-red-500/10 text-red-500 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||||
|
title="Cancelar"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditClick(move)}
|
||||||
|
className="p-1.5 text-blue-500 hover:bg-blue-500/10 rounded-lg transition-colors"
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(move._id!, move.quantity)}
|
||||||
|
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Excluir"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// LOGS TABLE
|
||||||
|
filteredLogs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-text-muted">Nenhum log de auditoria encontrado.</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-border/40 bg-surface">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="bg-surface-soft text-text-muted font-medium uppercase text-xs">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 w-40">Data</th>
|
||||||
|
<th className="px-4 py-3 w-32">Ação</th>
|
||||||
|
<th className="px-4 py-3 w-32 text-center">ID Mov.</th>
|
||||||
|
<th className="px-4 py-3 w-40">Usuário</th>
|
||||||
|
<th className="px-4 py-3">Detalhes da Alteração</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/40">
|
||||||
|
{filteredLogs.map((log) => (
|
||||||
|
<tr key={log._id} className="hover:bg-surface-hover/50">
|
||||||
|
<td className="px-4 py-3 text-text-main align-top whitespace-nowrap">
|
||||||
|
{format(new Date(log.timestamp), 'dd/MM/yyyy HH:mm')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 align-top font-bold">
|
||||||
|
<span className={
|
||||||
|
log.action === 'CREATE' ? 'text-green-500' :
|
||||||
|
log.action === 'UPDATE' ? 'text-blue-500' :
|
||||||
|
'text-red-500'
|
||||||
|
}>
|
||||||
|
{log.action === 'CREATE' ? 'CRIAÇÃO' :
|
||||||
|
log.action === 'UPDATE' ? 'EDIÇÃO' : 'EXCLUSÃO'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center align-top font-mono text-text-main">
|
||||||
|
{formatMovementId(log.movementNumber)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-secondary align-top">
|
||||||
|
{log.userName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted text-xs align-top font-mono">
|
||||||
|
{log.details}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
268
src/client/components/modals/StockModal.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { stockService, type StockItem } from '../../services/stockService';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
|
||||||
|
interface StockModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
initialData?: StockItem;
|
||||||
|
initialType?: 'PAINT' | 'THINNER';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSuccess, initialData, initialType = 'PAINT' }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dataSheets, setDataSheets] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Form Data
|
||||||
|
const [dataSheetId, setDataSheetId] = useState('');
|
||||||
|
const [rrNumber, setRrNumber] = useState('');
|
||||||
|
const [batchNumber, setBatchNumber] = useState('');
|
||||||
|
const [color, setColor] = useState('');
|
||||||
|
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||||||
|
const [receivedBy, setReceivedBy] = useState('');
|
||||||
|
const [quantity, setQuantity] = useState('');
|
||||||
|
const [unit, setUnit] = useState('L');
|
||||||
|
const [expirationDate, setExpirationDate] = useState('');
|
||||||
|
const [minStock, setMinStock] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDataSheets = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/datasheets'); // Assuming this endpoint exists and lists all
|
||||||
|
setDataSheets(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching datasheets", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
fetchDataSheets();
|
||||||
|
if (initialData) {
|
||||||
|
setDataSheetId(typeof initialData.dataSheetId === 'object' ? initialData.dataSheetId._id : initialData.dataSheetId);
|
||||||
|
setRrNumber(initialData.rrNumber);
|
||||||
|
setBatchNumber(initialData.batchNumber);
|
||||||
|
setColor(initialData.color || '');
|
||||||
|
setInvoiceNumber(initialData.invoiceNumber || '');
|
||||||
|
setReceivedBy(initialData.receivedBy || '');
|
||||||
|
setQuantity(String(initialData.quantity));
|
||||||
|
setUnit(initialData.unit);
|
||||||
|
setExpirationDate(initialData.expirationDate ? new Date(initialData.expirationDate).toISOString().split('T')[0] : '');
|
||||||
|
setMinStock(String(initialData.minStock || 0));
|
||||||
|
setNotes(initialData.notes || '');
|
||||||
|
} else {
|
||||||
|
// Reset form
|
||||||
|
setDataSheetId('');
|
||||||
|
setRrNumber('');
|
||||||
|
setBatchNumber('');
|
||||||
|
setColor('');
|
||||||
|
setInvoiceNumber('');
|
||||||
|
setReceivedBy('');
|
||||||
|
setQuantity('');
|
||||||
|
setUnit('L');
|
||||||
|
setExpirationDate('');
|
||||||
|
setMinStock('0');
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, initialData]);
|
||||||
|
|
||||||
|
// Handle filling color etc if picking a DataSheet (Optional feature, not implemented yet)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
dataSheetId,
|
||||||
|
rrNumber,
|
||||||
|
batchNumber,
|
||||||
|
color,
|
||||||
|
invoiceNumber,
|
||||||
|
receivedBy,
|
||||||
|
unit,
|
||||||
|
expirationDate: expirationDate || undefined,
|
||||||
|
minStock: Number(minStock) || 0,
|
||||||
|
notes
|
||||||
|
};
|
||||||
|
|
||||||
|
// If creating, send quantity. If updating, DO NOT send quantity (handled via adjusts)
|
||||||
|
if (!initialData) {
|
||||||
|
payload.quantity = Number(quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (initialData) {
|
||||||
|
await stockService.update(initialData._id!, payload);
|
||||||
|
} else {
|
||||||
|
await stockService.create(payload);
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving stock item:', error);
|
||||||
|
alert(error.response?.data?.error || 'Erro ao salvar item.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const isThinner = initialData
|
||||||
|
? (typeof initialData.dataSheetId === 'object' && (initialData.dataSheetId.type === 'THINNER' || initialData.dataSheetId.type === 'DILUENTE'))
|
||||||
|
: (initialType === 'THINNER');
|
||||||
|
|
||||||
|
const filteredDataSheets = dataSheets.filter(ds => {
|
||||||
|
const dsType = ds.type || 'PAINT';
|
||||||
|
const isDsThinner = dsType === 'THINNER' || dsType === 'DILUENTE';
|
||||||
|
return isThinner ? isDsThinner : !isDsThinner;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={initialData ? "Editar Detalhes do Lote" : `Nova Entrada de Estoque (${isThinner ? 'Diluente' : 'Tinta'})`}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Select
|
||||||
|
label="Produto (Ficha Técnica)"
|
||||||
|
name="dataSheetId"
|
||||||
|
value={dataSheetId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setDataSheetId(val);
|
||||||
|
// Auto-fill minStock from DataSheet if set and current is empty/0
|
||||||
|
const ds = dataSheets.find(d => d._id === val);
|
||||||
|
if (ds && ds.minStock && (!minStock || minStock === '0')) {
|
||||||
|
setMinStock(String(ds.minStock));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={filteredDataSheets.map(ds => ({ label: `${ds.name} - ${ds.manufacturer}`, value: ds._id }))}
|
||||||
|
disabled={!!initialData} // Lock product on edit
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="RR (Rastreabilidade)"
|
||||||
|
name="rrNumber"
|
||||||
|
value={rrNumber}
|
||||||
|
onChange={(e) => setRrNumber(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={!!initialData} // Usually unique ID shouldn't change easily
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Lote Fabricante"
|
||||||
|
name="batchNumber"
|
||||||
|
value={batchNumber}
|
||||||
|
onChange={(e) => setBatchNumber(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Nota Fiscal"
|
||||||
|
name="invoiceNumber"
|
||||||
|
value={invoiceNumber}
|
||||||
|
onChange={(e) => setInvoiceNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Recebido Por"
|
||||||
|
name="receivedBy"
|
||||||
|
value={receivedBy}
|
||||||
|
onChange={(e) => setReceivedBy(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isThinner && (
|
||||||
|
<Input
|
||||||
|
label="Cor"
|
||||||
|
name="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
placeholder="Ex: Amarelo Segurança, CINZA N6.5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!initialData && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Quantidade Inicial"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Unidade"
|
||||||
|
name="unit"
|
||||||
|
value={unit}
|
||||||
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
|
options={[
|
||||||
|
{ label: 'Litros (L)', value: 'L' },
|
||||||
|
{ label: 'Galões (Gal)', value: 'Gal' },
|
||||||
|
{ label: 'Quartos (Qt)', value: 'Qt' },
|
||||||
|
{ label: 'Kg', value: 'Kg' },
|
||||||
|
{ label: 'Unidade (Un)', value: 'Un' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{!isThinner && (
|
||||||
|
<Input
|
||||||
|
label="Data de Validade"
|
||||||
|
name="expirationDate"
|
||||||
|
type="date"
|
||||||
|
value={expirationDate}
|
||||||
|
onChange={(e) => setExpirationDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={isThinner ? "col-span-2" : ""}>
|
||||||
|
<Input
|
||||||
|
label="Estoque Mínimo (L)"
|
||||||
|
name="minStock"
|
||||||
|
type="number"
|
||||||
|
value={minStock}
|
||||||
|
onChange={(e) => setMinStock(e.target.value)}
|
||||||
|
placeholder="Qtd de alerta"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Observações"
|
||||||
|
name="notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Salvando...' : 'Salvar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal >
|
||||||
|
);
|
||||||
|
};
|
||||||
165
src/client/components/modals/StockOutModal.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal } from '../Modal';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { Select } from '../Select';
|
||||||
|
import { stockService, type StockItem } from '../../services/stockService';
|
||||||
|
import { useAuth } from '../../context/useAuth';
|
||||||
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
|
||||||
|
interface StockOutModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
item: StockItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StockOutModal: React.FC<StockOutModalProps> = ({ isOpen, onClose, onSuccess, item }) => {
|
||||||
|
const { isGuest } = useAuth();
|
||||||
|
const { showGuestWarning } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [type, setType] = useState<'CONSUMPTION' | 'ADJUSTMENT'>('CONSUMPTION');
|
||||||
|
const [quantity, setQuantity] = useState('');
|
||||||
|
|
||||||
|
// Adjustment fields
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
// Consumption fields
|
||||||
|
const [requester, setRequester] = useState('');
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (isGuest()) {
|
||||||
|
showGuestWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qtyNum = Number(quantity);
|
||||||
|
if (!qtyNum || qtyNum <= 0) {
|
||||||
|
alert('Quantidade deve ser maior que zero.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'ADJUSTMENT' && !reason) {
|
||||||
|
alert('Motivo é obrigatório para ajustes.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'CONSUMPTION' && !requester) {
|
||||||
|
alert('Solicitante é obrigatório para consumo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === 'ADJUSTMENT') {
|
||||||
|
// Adjust can be positive or negative, but here we frame it as "Stock Out" mostly?
|
||||||
|
// Actually the requirement is "Two systems of Stock Out". So Adjustment implies REMOVING?
|
||||||
|
// Or "Correction" which could be adding?
|
||||||
|
// Let's assume this modal is generic, but usually used for outs.
|
||||||
|
// However, "Baixa por ajuste técnico" implies reducing.
|
||||||
|
// But typically adjustment allows both. Let's send negative quantity for reducing.
|
||||||
|
|
||||||
|
await stockService.adjust(item._id!, {
|
||||||
|
quantityDelta: -qtyNum, // Negative for removal
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await stockService.consume(item._id!, {
|
||||||
|
quantityConsumed: qtyNum,
|
||||||
|
requester,
|
||||||
|
date
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error processing stock out:', error);
|
||||||
|
alert(error.response?.data?.error || 'Erro ao realizar baixa.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Realizar Baixa - ${item.rrNumber}`}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mb-4">
|
||||||
|
<p className="text-sm font-semibold text-text-muted">Item Selecionado:</p>
|
||||||
|
<p className="text-lg font-bold text-text-main">
|
||||||
|
{typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Produto'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-secondary">Lote: {item.batchNumber} | Cor: {item.color || '-'}</p>
|
||||||
|
<p className="text-sm text-text-secondary mt-1">
|
||||||
|
Disponível: <span className="font-bold text-green-500">{item.quantity} {item.unit}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Tipo de Baixa"
|
||||||
|
name="type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as any)}
|
||||||
|
options={[
|
||||||
|
{ label: 'Consumo em Obra', value: 'CONSUMPTION' },
|
||||||
|
{ label: 'Ajuste Técnico / Perda', value: 'ADJUSTMENT' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Quantidade a Baixar"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder={`Max: ${item.quantity}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{type === 'ADJUSTMENT' ? (
|
||||||
|
<Input
|
||||||
|
label="Motivo do Ajuste"
|
||||||
|
name="reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Ex: Material vencido, Pote danificado, Desperdício teste..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label="Solicitante (Encarregado/Pintor)"
|
||||||
|
name="requester"
|
||||||
|
value={requester}
|
||||||
|
onChange={(e) => setRequester(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Data do Consumo"
|
||||||
|
name="date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="danger" disabled={loading}>
|
||||||
|
{loading ? 'Processando...' : 'Confirmar Baixa'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
186
src/client/components/reports/AnalyticalReport.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useRef, useLayoutEffect } from 'react';
|
||||||
|
import { format, isValid } from 'date-fns';
|
||||||
|
import type { Project } from '../../types';
|
||||||
|
import '../../styles/reports.css';
|
||||||
|
|
||||||
|
interface AnalyticalReportProps {
|
||||||
|
project: Project;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressFill: React.FC<{ progress: number }> = ({ progress }) => {
|
||||||
|
const fillRef = useRef<HTMLDivElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (fillRef.current) {
|
||||||
|
fillRef.current.style.setProperty('--progress', `${progress}%`);
|
||||||
|
}
|
||||||
|
}, [progress]);
|
||||||
|
return <div ref={fillRef} className="evol-fill" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticalReport: React.FC<AnalyticalReportProps> = ({ project, logoUrl }) => {
|
||||||
|
const inspections = project.inspections || [];
|
||||||
|
const sumWeight = inspections.reduce((acc, curr) => acc + (curr.weightKg || 0), 0);
|
||||||
|
const totalWeight = project.weightKg || 0;
|
||||||
|
const progress = totalWeight > 0 ? Math.min(Math.round((sumWeight / totalWeight) * 100), 100) : 0;
|
||||||
|
|
||||||
|
// Período
|
||||||
|
const startDate = project.startDate ? new Date(project.startDate) : null;
|
||||||
|
const endDate = project.endDate ? new Date(project.endDate) : null;
|
||||||
|
const periodStr = (startDate && isValid(startDate) && endDate && isValid(endDate))
|
||||||
|
? `${format(startDate, 'MM/yyyy')} – ${format(endDate, 'MM/yyyy')}`
|
||||||
|
: '--/----';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-container print:block hidden" id="analytical-report">
|
||||||
|
<header className="report-header">
|
||||||
|
<div className="brand">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="Logo" className="brand-logo" />
|
||||||
|
) : (
|
||||||
|
<div className="logo-placeholder"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="brand-title">RELATÓRIO ANALÍTICO DE OBRA</div>
|
||||||
|
<div className="brand-subtitle">
|
||||||
|
Detalhamento de Inspeções, Aplicativos e Esquemas de Pintura
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta">
|
||||||
|
<div><strong>Data:</strong> {format(new Date(), 'dd/MM/yyyy')}</div>
|
||||||
|
<div><strong>Obra:</strong> {project.name.toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="summary">
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Evolução Real</div>
|
||||||
|
<div className="summary-value">{progress}%</div>
|
||||||
|
<div className="evol-bar">
|
||||||
|
<ProgressFill progress={progress} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Peso Medido (kgf)</div>
|
||||||
|
<div className="summary-value">{sumWeight.toLocaleString('pt-BR')}</div>
|
||||||
|
<div className="summary-sub">de {totalWeight.toLocaleString('pt-BR')} total</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Responsável</div>
|
||||||
|
<div className="summary-value text-11pt">{project.technician || '________________'}</div>
|
||||||
|
<div className="summary-sub">Técnico Encarregado</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Período de Obra</div>
|
||||||
|
<div className="summary-value text-11pt">{periodStr}</div>
|
||||||
|
<div className="summary-sub">Cronograma Previsto</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>ESQUEMA DE PINTURA REQUERIDO</h2>
|
||||||
|
<span>Especificação técnica por demão</span>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-20">Etapa</th>
|
||||||
|
<th className="w-40">Produto</th>
|
||||||
|
<th className="w-20">Cor</th>
|
||||||
|
<th className="w-20">EPS (μm)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{project.paintingSchemes?.map((s, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="font-bold uppercase">{s.coat || s.type}</td>
|
||||||
|
<td>{s.name.toUpperCase()}</td>
|
||||||
|
<td>{s.color || '--'}</td>
|
||||||
|
<td className="font-bold">{s.epsMin}-{s.epsMax} μm</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(!project.paintingSchemes || project.paintingSchemes.length === 0) && (
|
||||||
|
<tr><td colSpan={4} className="text-center p-10mm text-gray-muted">Nenhum esquema definido</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="grid-2col">
|
||||||
|
<div>
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>INSPEÇÕES REALIZADAS</h2>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-25">Data</th>
|
||||||
|
<th className="w-40">Peça / Área</th>
|
||||||
|
<th className="w-20">Peso</th>
|
||||||
|
<th className="w-15">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{inspections.slice(0, 15).map((insp, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td>{insp.date ? format(new Date(insp.date), 'dd/MM/yy') : '--'}</td>
|
||||||
|
<td className="uppercase font-medium text-8pt">{insp.pieceDescription}</td>
|
||||||
|
<td className="font-bold">{insp.weightKg?.toLocaleString('pt-BR')}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${insp.appearance === 'approved' ? 'badge-ok' : 'badge-err'}`}>
|
||||||
|
{insp.appearance === 'approved' ? 'OK' : 'REJ'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{inspections.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="text-center p-5mm text-gray-muted">Sem registros</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>REGISTROS DE APLICAÇÃO</h2>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-25">Data</th>
|
||||||
|
<th className="w-35">Etapa</th>
|
||||||
|
<th className="w-20">EPS Seca</th>
|
||||||
|
<th className="w-20">Pintor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{project.applicationRecords?.slice(0, 15).map((record, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td>{record.date ? format(new Date(record.date), 'dd/MM/yy') : '--'}</td>
|
||||||
|
<td className="uppercase font-medium text-8pt">{record.coatStage}</td>
|
||||||
|
<td className="font-bold">{record.dryThicknessCalc || '--'} μm</td>
|
||||||
|
<td className="uppercase text-7pt">{record.operator?.split(' ')[0]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(!project.applicationRecords || project.applicationRecords.length === 0) && (
|
||||||
|
<tr><td colSpan={4} className="text-center p-5mm text-gray-muted">Sem registros</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="report-footer">
|
||||||
|
<div className="system-title">
|
||||||
|
SteelPaint - Gestão de Pintura Industrial
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Gerado em {format(new Date(), 'dd/MM/yyyy')} às {format(new Date(), 'HH:mm')}h
|
||||||
|
</div>
|
||||||
|
<div className="sig-group">
|
||||||
|
<div className="sig-line">Responsável Qualidade<span></span></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
174
src/client/components/reports/GeneralProjectReport.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useRef, useLayoutEffect } from 'react';
|
||||||
|
import { format, min, max, isValid } from 'date-fns';
|
||||||
|
import type { Project, Inspection } from '../../types';
|
||||||
|
import '../../styles/reports.css';
|
||||||
|
|
||||||
|
interface GeneralProjectReportProps {
|
||||||
|
projects: Project[];
|
||||||
|
inspections?: Inspection[];
|
||||||
|
title: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressFill: React.FC<{ progress: number }> = ({ progress }) => {
|
||||||
|
const fillRef = useRef<HTMLDivElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (fillRef.current) {
|
||||||
|
fillRef.current.style.setProperty('--progress', `${progress}%`);
|
||||||
|
}
|
||||||
|
}, [progress]);
|
||||||
|
return <div ref={fillRef} className="evol-fill" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GeneralProjectReport: React.FC<GeneralProjectReportProps> = ({ projects, inspections = [], title, logoUrl }) => {
|
||||||
|
// Cálculos globais
|
||||||
|
const totalWeight = projects.reduce((acc, p) => acc + (p.weightKg || 0), 0);
|
||||||
|
|
||||||
|
const calculateProjectProgress = (project: Project) => {
|
||||||
|
const projectInspections = inspections.filter(i => i.projectId === project.id);
|
||||||
|
const sumWeight = projectInspections.reduce((acc, curr) => acc + (curr.weightKg || 0), 0);
|
||||||
|
const totalW = project.weightKg || 0;
|
||||||
|
return totalW > 0 ? Math.min((sumWeight / totalW) * 100, 100) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const avgProgress = projects.length > 0
|
||||||
|
? projects.reduce((acc, p) => acc + calculateProjectProgress(p), 0) / projects.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Período (Min/Max das datas)
|
||||||
|
const allDates = projects.flatMap(p => [
|
||||||
|
p.startDate ? new Date(p.startDate) : null,
|
||||||
|
p.endDate ? new Date(p.endDate) : null
|
||||||
|
]).filter((d): d is Date => d !== null && isValid(d));
|
||||||
|
|
||||||
|
const periodStart = allDates.length > 0 ? format(min(allDates), 'MM/yyyy') : '--/----';
|
||||||
|
const periodEnd = allDates.length > 0 ? format(max(allDates), 'MM/yyyy') : '--/----';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-container print:block hidden" id="general-project-report">
|
||||||
|
<header className="report-header">
|
||||||
|
<div className="brand">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt="Logo" className="brand-logo" />
|
||||||
|
) : (
|
||||||
|
<div className="logo-placeholder"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="brand-title">{title.toUpperCase()}</div>
|
||||||
|
<div className="brand-subtitle">
|
||||||
|
Obras / Projetos – Situação de produção e pintura
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="meta">
|
||||||
|
<div><strong>Data:</strong> {format(new Date(), 'dd/MM/yyyy')}</div>
|
||||||
|
<div><strong>Responsável:</strong> ________________</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="summary">
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Total de obras</div>
|
||||||
|
<div className="summary-value">{projects.length}</div>
|
||||||
|
<div className="summary-sub">Listadas neste relatório</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Peso total (kgf)</div>
|
||||||
|
<div className="summary-value">{totalWeight.toLocaleString('pt-BR')}</div>
|
||||||
|
<div className="summary-sub">Soma dos projetos</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Evolução média</div>
|
||||||
|
<div className="summary-value">{avgProgress.toFixed(1).replace('.', ',')}%</div>
|
||||||
|
<div className="summary-sub">Estimativa geral</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-item">
|
||||||
|
<div className="summary-label">Período</div>
|
||||||
|
<div className="summary-value">{periodStart} – {periodEnd}</div>
|
||||||
|
<div className="summary-sub">Previsão de execução</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="section-title">
|
||||||
|
<h2>OBRAS / PROJETOS</h2>
|
||||||
|
<span>Visão geral por cronograma, peso e sistema de pintura</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="col-obra">Obra / Projeto</th>
|
||||||
|
<th className="col-evol">Evol.</th>
|
||||||
|
<th className="col-cron">Cronograma</th>
|
||||||
|
<th className="col-peso text-center">Peso (kgf)</th>
|
||||||
|
<th className="col-tinta">Tinta</th>
|
||||||
|
<th className="col-cor">Cor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((project) => {
|
||||||
|
const progress = calculateProjectProgress(project);
|
||||||
|
const schemes = project.paintingSchemes || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={project.id}>
|
||||||
|
<td className="col-obra">
|
||||||
|
<div className="obra-nome">{project.name.toUpperCase()}</div>
|
||||||
|
<div className="obra-cliente">Cliente: {project.client}</div>
|
||||||
|
<div className="obra-cliente">Gestor: {project.technician || '--'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="col-evol">
|
||||||
|
<div className="font-bold">{Math.round(progress)}%</div>
|
||||||
|
<div className="evol-bar">
|
||||||
|
<ProgressFill progress={progress} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="col-cron">
|
||||||
|
<div className="cron">
|
||||||
|
<strong>Início:</strong> {project.startDate ? format(new Date(project.startDate), 'dd/MM/yyyy') : '--/--/----'}<br />
|
||||||
|
<strong>Término:</strong> {project.endDate ? format(new Date(project.endDate), 'dd/MM/yyyy') : '--/--/----'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="col-peso text-center">
|
||||||
|
<div className="font-bold">
|
||||||
|
{(project.weightKg || 0).toLocaleString('pt-BR')}
|
||||||
|
<span className="block text-[7pt] text-gray-400 font-normal">Est. total</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="col-tinta">
|
||||||
|
<div className="tinta">
|
||||||
|
{schemes.length > 0 ? schemes.slice(0, 2).map((s, idx) => (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<strong>{s.name.toUpperCase()}</strong>
|
||||||
|
{s.coat || s.type || 'Esquema'}{idx < schemes.length - 1 && <br />}
|
||||||
|
</React.Fragment>
|
||||||
|
)) : <span className="text-gray-400 italic">Sem esquema</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="col-cor">
|
||||||
|
<div className="text-[8pt] leading-tight">
|
||||||
|
{schemes.length > 0 ? schemes.slice(0, 2).map((s, idx) => (
|
||||||
|
<div key={idx}>{s.color || '-'}</div>
|
||||||
|
)) : '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<footer className="report-footer">
|
||||||
|
<div className="system-title">
|
||||||
|
SteelPaint - Gestão de Pintura Industrial
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Gerado em {format(new Date(), 'dd/MM/yyyy')} às {format(new Date(), 'HH:mm')}h
|
||||||
|
</div>
|
||||||
|
<div className="sig-group">
|
||||||
|
<div className="sig-line">Responsável Qualidade<span></span></div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
270
src/client/components/reports/StockInventoryReport.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
src/client/context/AuthContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
14
src/client/context/AuthContextType.ts
Normal 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>;
|
||||||
|
}
|
||||||
56
src/client/context/SystemSettingsContext.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { systemSettingsService } from '../services/systemSettingsService';
|
||||||
|
import type { SystemSettings } from '../services/systemSettingsService';
|
||||||
|
|
||||||
|
interface SystemSettingsContextType {
|
||||||
|
settings: SystemSettings | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
updateSettings: (newSettings: Partial<SystemSettings>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemSettingsContext = createContext<SystemSettingsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const SystemSettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [settings, setSettings] = useState<SystemSettings | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await systemSettingsService.getSettings();
|
||||||
|
setSettings(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load system settings:', error);
|
||||||
|
// Set defaults if fetch fails
|
||||||
|
setSettings({
|
||||||
|
settingsId: 'global',
|
||||||
|
appName: 'GPI',
|
||||||
|
appSubtitle: 'Gestão de Pintura Industrial'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSettingsValue = async (newSettings: Partial<SystemSettings>) => {
|
||||||
|
const updated = await systemSettingsService.updateSettings(newSettings);
|
||||||
|
setSettings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, [fetchSettings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SystemSettingsContext.Provider value={{ settings, isLoading, updateSettings: updateSettingsValue }}>
|
||||||
|
{children}
|
||||||
|
</SystemSettingsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSystemSettings = () => {
|
||||||
|
const context = useContext(SystemSettingsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSystemSettings must be used within a SystemSettingsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
22
src/client/context/ToastContext.tsx
Normal file
@@ -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;
|
||||||
|
};
|
||||||
1
src/client/context/useAuth.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useAuth } from './AuthContext';
|
||||||
77
src/client/contexts/NotificationContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/client/contexts/NotificationContextState.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
import type { INotification } from '../types';
|
||||||
|
|
||||||
|
export interface NotificationContextData {
|
||||||
|
notifications: INotification[];
|
||||||
|
unreadCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
markAsRead: (id: string) => Promise<void>;
|
||||||
|
markAllAsRead: () => Promise<void>;
|
||||||
|
clearAll: () => Promise<void>;
|
||||||
|
archiveNotification: (id: string) => Promise<void>;
|
||||||
|
deleteNotification: (id: string) => Promise<void>;
|
||||||
|
fetchNotifications: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationContext = createContext<NotificationContextData>({} as NotificationContextData);
|
||||||
10
src/client/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { NotificationContext } from '../contexts/NotificationContextState';
|
||||||
|
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const context = useContext(NotificationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotifications must be used within a NotificationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
58
src/client/hooks/usePresence.ts
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
2
src/client/hooks/useToast.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export from the new context file to maintain backward compatibility
|
||||||
|
export { useToast } from '../context/ToastContext';
|
||||||
45
src/client/hooks/useUnreadMessages.ts
Normal file
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<App />
|
||||||
|
);
|
||||||
408
src/client/pages/AdminDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||